@forgeax/engine-net 0.0.0-dev.8d955ade1c79
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/LICENSE +202 -0
- package/README.md +196 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/endpoint/endpoint.d.ts +43 -0
- package/dist/endpoint/endpoint.d.ts.map +1 -0
- package/dist/endpoint/errors.d.ts +82 -0
- package/dist/endpoint/errors.d.ts.map +1 -0
- package/dist/endpoint/memory.d.ts +15 -0
- package/dist/endpoint/memory.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1648 -0
- package/dist/index.mjs.map +1 -0
- package/dist/replication/authority.d.ts +19 -0
- package/dist/replication/authority.d.ts.map +1 -0
- package/dist/replication/codec.d.ts +8 -0
- package/dist/replication/codec.d.ts.map +1 -0
- package/dist/replication/constants.d.ts +5 -0
- package/dist/replication/constants.d.ts.map +1 -0
- package/dist/replication/errors.d.ts +86 -0
- package/dist/replication/errors.d.ts.map +1 -0
- package/dist/replication/handshake.d.ts +5 -0
- package/dist/replication/handshake.d.ts.map +1 -0
- package/dist/replication/profile.d.ts +31 -0
- package/dist/replication/profile.d.ts.map +1 -0
- package/dist/replication/protocol.d.ts +63 -0
- package/dist/replication/protocol.d.ts.map +1 -0
- package/dist/replication/replica.d.ts +30 -0
- package/dist/replication/replica.d.ts.map +1 -0
- package/dist/session/net-session.d.ts +66 -0
- package/dist/session/net-session.d.ts.map +1 -0
- package/dist/session/recovery.d.ts +93 -0
- package/dist/session/recovery.d.ts.map +1 -0
- package/dist/session/session-plugin.d.ts +14 -0
- package/dist/session/session-plugin.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/endpoint/endpoint.ts +58 -0
- package/src/endpoint/errors.ts +164 -0
- package/src/endpoint/memory.ts +194 -0
- package/src/index.ts +90 -0
- package/src/replication/authority.ts +177 -0
- package/src/replication/codec.ts +323 -0
- package/src/replication/constants.ts +5 -0
- package/src/replication/errors.ts +78 -0
- package/src/replication/handshake.ts +18 -0
- package/src/replication/profile.ts +111 -0
- package/src/replication/protocol.ts +86 -0
- package/src/replication/replica.ts +301 -0
- package/src/session/net-session.ts +697 -0
- package/src/session/recovery.ts +204 -0
- package/src/session/session-plugin.ts +68 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1648 @@
|
|
|
1
|
+
import { err, ok } from '@forgeax/engine-types';
|
|
2
|
+
import { validateProfileComponents, projectComponentData, classifyEntityField } from '@forgeax/engine-ecs/externalization';
|
|
3
|
+
import { componentSchema } from '@forgeax/engine-ecs/internal';
|
|
4
|
+
import { Update, FixedUpdate } from '@forgeax/engine-ecs';
|
|
5
|
+
|
|
6
|
+
// src/endpoint/errors.ts
|
|
7
|
+
var EndpointErrorClass = class extends Error {
|
|
8
|
+
code;
|
|
9
|
+
expected;
|
|
10
|
+
hint;
|
|
11
|
+
detail;
|
|
12
|
+
constructor(args) {
|
|
13
|
+
let suffix = "";
|
|
14
|
+
if (args.code === "peer-not-found") {
|
|
15
|
+
const d = args.detail;
|
|
16
|
+
suffix = ` (peerId=${d.peerId})`;
|
|
17
|
+
} else if (args.code === "connection-closed") {
|
|
18
|
+
const d = args.detail;
|
|
19
|
+
suffix = ` (peerId=${d.peerId})`;
|
|
20
|
+
} else if (args.code === "send-failed") {
|
|
21
|
+
const d = args.detail;
|
|
22
|
+
suffix = ` (peerId=${d.peerId}, cause=${d.cause})`;
|
|
23
|
+
} else if (args.code === "already-closed") {
|
|
24
|
+
const d = args.detail;
|
|
25
|
+
suffix = ` (cause=${d.cause})`;
|
|
26
|
+
} else if (args.code === "connection-failed") {
|
|
27
|
+
const d = args.detail;
|
|
28
|
+
suffix = ` (address=${d.address}, cause=${d.cause})`;
|
|
29
|
+
}
|
|
30
|
+
super(`[EndpointError ${args.code}] expected: ${args.expected}; hint: ${args.hint}${suffix}`);
|
|
31
|
+
this.name = "EndpointError";
|
|
32
|
+
this.code = args.code;
|
|
33
|
+
this.expected = args.expected;
|
|
34
|
+
this.hint = args.hint;
|
|
35
|
+
this.detail = args.detail;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
var EndpointError = EndpointErrorClass;
|
|
39
|
+
var endpointErrorPolicy = {
|
|
40
|
+
"peer-not-found": {
|
|
41
|
+
expected: "the target peer must exist in the current connection set",
|
|
42
|
+
hint: "verify the PeerId is from a connect event; check that the peer has not disconnected"
|
|
43
|
+
},
|
|
44
|
+
"connection-closed": {
|
|
45
|
+
expected: "the peer connection must be alive for the operation",
|
|
46
|
+
hint: "the peer disconnected; poll for a disconnect event and handle the lifecycle"
|
|
47
|
+
},
|
|
48
|
+
"send-failed": {
|
|
49
|
+
expected: "message bytes must be delivered to the target peer or the connection must fail",
|
|
50
|
+
hint: "the memory connection is broken; the peer may have disconnected or the buffer is full"
|
|
51
|
+
},
|
|
52
|
+
"already-closed": {
|
|
53
|
+
expected: "the endpoint must be open for any operation",
|
|
54
|
+
hint: "the endpoint is closed; create a new endpoint pair for further communication"
|
|
55
|
+
},
|
|
56
|
+
"connection-failed": {
|
|
57
|
+
expected: "the endpoint factory must successfully establish a connection or bind to the listen address",
|
|
58
|
+
hint: "the initial connection or bind failed; verify the address is reachable and the port is not in use, then retry"
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
var ENDPOINT_EXPECTED = Object.fromEntries(
|
|
62
|
+
Object.entries(endpointErrorPolicy).map(([code, policy]) => [code, policy.expected])
|
|
63
|
+
);
|
|
64
|
+
var ENDPOINT_ERROR_HINTS = Object.fromEntries(
|
|
65
|
+
Object.entries(endpointErrorPolicy).map(([code, policy]) => [code, policy.hint])
|
|
66
|
+
);
|
|
67
|
+
function isEndpointError(err9) {
|
|
68
|
+
return err9 instanceof EndpointErrorClass;
|
|
69
|
+
}
|
|
70
|
+
var MemoryEndpoint = class {
|
|
71
|
+
_peerId;
|
|
72
|
+
_remote = null;
|
|
73
|
+
_closed = false;
|
|
74
|
+
_remoteConnected = false;
|
|
75
|
+
_incoming = [];
|
|
76
|
+
_delayed = [];
|
|
77
|
+
_state = { delayNext: false, duplicateNext: false, malformNext: false };
|
|
78
|
+
constructor(peerId) {
|
|
79
|
+
this._peerId = peerId;
|
|
80
|
+
}
|
|
81
|
+
poll() {
|
|
82
|
+
if (this._closed) return [];
|
|
83
|
+
const events = this._incoming.splice(0);
|
|
84
|
+
this._incoming = this._delayed.splice(0);
|
|
85
|
+
return events;
|
|
86
|
+
}
|
|
87
|
+
send(peerId, data) {
|
|
88
|
+
if (this._closed) {
|
|
89
|
+
return err(
|
|
90
|
+
new EndpointError({
|
|
91
|
+
code: "already-closed",
|
|
92
|
+
expected: ENDPOINT_EXPECTED["already-closed"],
|
|
93
|
+
hint: ENDPOINT_ERROR_HINTS["already-closed"],
|
|
94
|
+
detail: { cause: "endpoint is closed" }
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!this._remote || this._remote._peerId !== peerId) {
|
|
99
|
+
return err(
|
|
100
|
+
new EndpointError({
|
|
101
|
+
code: "peer-not-found",
|
|
102
|
+
expected: ENDPOINT_EXPECTED["peer-not-found"],
|
|
103
|
+
hint: ENDPOINT_ERROR_HINTS["peer-not-found"],
|
|
104
|
+
detail: { peerId }
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (!this._remoteConnected) {
|
|
109
|
+
return err(
|
|
110
|
+
new EndpointError({
|
|
111
|
+
code: "connection-closed",
|
|
112
|
+
expected: ENDPOINT_EXPECTED["connection-closed"],
|
|
113
|
+
hint: ENDPOINT_ERROR_HINTS["connection-closed"],
|
|
114
|
+
detail: { peerId }
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const deliver = (bytes) => {
|
|
119
|
+
if (this._state.delayNext) {
|
|
120
|
+
this._remote?._delayed.push({ kind: "message", peerId: this._peerId, data: bytes });
|
|
121
|
+
this._state.delayNext = false;
|
|
122
|
+
} else {
|
|
123
|
+
this._remote?._incoming.push({ kind: "message", peerId: this._peerId, data: bytes });
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
if (this._state.malformNext) {
|
|
127
|
+
const corrupted = new Uint8Array(data);
|
|
128
|
+
if (corrupted.length > 0) {
|
|
129
|
+
const firstByte = corrupted[0];
|
|
130
|
+
if (firstByte !== void 0) corrupted[0] = firstByte ^ 255;
|
|
131
|
+
}
|
|
132
|
+
deliver(corrupted);
|
|
133
|
+
this._state.malformNext = false;
|
|
134
|
+
} else {
|
|
135
|
+
deliver(data);
|
|
136
|
+
if (this._state.duplicateNext) {
|
|
137
|
+
this._state.duplicateNext = false;
|
|
138
|
+
if (this._state.delayNext) {
|
|
139
|
+
this._remote?._delayed.push({ kind: "message", peerId: this._peerId, data });
|
|
140
|
+
this._state.delayNext = false;
|
|
141
|
+
} else {
|
|
142
|
+
this._remote?._incoming.push({ kind: "message", peerId: this._peerId, data });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return ok(void 0);
|
|
147
|
+
}
|
|
148
|
+
close() {
|
|
149
|
+
if (this._closed) {
|
|
150
|
+
return err(
|
|
151
|
+
new EndpointError({
|
|
152
|
+
code: "already-closed",
|
|
153
|
+
expected: ENDPOINT_EXPECTED["already-closed"],
|
|
154
|
+
hint: ENDPOINT_ERROR_HINTS["already-closed"],
|
|
155
|
+
detail: { cause: "endpoint is already closed" }
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
this._closed = true;
|
|
160
|
+
this._remoteConnected = false;
|
|
161
|
+
if (this._remote && !this._remote._closed) {
|
|
162
|
+
this._remote._remoteConnected = false;
|
|
163
|
+
this._remote._incoming.push({ kind: "peer-disconnected", peerId: this._peerId });
|
|
164
|
+
}
|
|
165
|
+
return ok(void 0);
|
|
166
|
+
}
|
|
167
|
+
_forceDisconnect() {
|
|
168
|
+
if (this._remote && !this._remote._closed) {
|
|
169
|
+
this._remote._remoteConnected = false;
|
|
170
|
+
this._remote._incoming.push({ kind: "peer-disconnected", peerId: this._peerId });
|
|
171
|
+
}
|
|
172
|
+
this._remoteConnected = false;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
function createMemoryEndpointPair() {
|
|
176
|
+
const epA = new MemoryEndpoint(1);
|
|
177
|
+
const epB = new MemoryEndpoint(2);
|
|
178
|
+
epA._remote = epB;
|
|
179
|
+
epB._remote = epA;
|
|
180
|
+
epA._remoteConnected = true;
|
|
181
|
+
epB._remoteConnected = true;
|
|
182
|
+
epA._incoming.push({ kind: "peer-connected", peerId: 2 });
|
|
183
|
+
epB._incoming.push({ kind: "peer-connected", peerId: 1 });
|
|
184
|
+
return [epA, epB];
|
|
185
|
+
}
|
|
186
|
+
function createMemoryEndpointPairWithController() {
|
|
187
|
+
const [epA, epB] = createMemoryEndpointPair();
|
|
188
|
+
const controller = {
|
|
189
|
+
delayNextDelivery(_ms) {
|
|
190
|
+
epA._state.delayNext = true;
|
|
191
|
+
},
|
|
192
|
+
duplicateNextDelivery() {
|
|
193
|
+
epA._state.duplicateNext = true;
|
|
194
|
+
},
|
|
195
|
+
malformNextDelivery() {
|
|
196
|
+
epA._state.malformNext = true;
|
|
197
|
+
},
|
|
198
|
+
disconnectPeer(endpoint) {
|
|
199
|
+
endpoint._forceDisconnect();
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
return { endpoints: [epA, epB], controller };
|
|
203
|
+
}
|
|
204
|
+
function createMemoryEndpointConnector(createEndpoint) {
|
|
205
|
+
return {
|
|
206
|
+
connect(signal) {
|
|
207
|
+
if (signal.aborted)
|
|
208
|
+
return Promise.resolve(
|
|
209
|
+
err(
|
|
210
|
+
new EndpointError({
|
|
211
|
+
code: "connection-failed",
|
|
212
|
+
expected: ENDPOINT_EXPECTED["connection-failed"],
|
|
213
|
+
hint: ENDPOINT_ERROR_HINTS["connection-failed"],
|
|
214
|
+
detail: { address: "memory", cause: "connect aborted" }
|
|
215
|
+
})
|
|
216
|
+
)
|
|
217
|
+
);
|
|
218
|
+
return Promise.resolve(ok(createEndpoint()));
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/replication/constants.ts
|
|
224
|
+
var REPLICATION_PROTOCOL_VERSION = 2;
|
|
225
|
+
var REPLICATION_PROTOCOL_PREFIX = "FXRP2";
|
|
226
|
+
|
|
227
|
+
// src/replication/errors.ts
|
|
228
|
+
var NetErrorClass = class extends Error {
|
|
229
|
+
code;
|
|
230
|
+
expected;
|
|
231
|
+
hint;
|
|
232
|
+
detail;
|
|
233
|
+
constructor(args) {
|
|
234
|
+
super(`[NetError ${args.code}] expected: ${args.expected}; hint: ${args.hint}`);
|
|
235
|
+
this.name = "NetError";
|
|
236
|
+
this.code = args.code;
|
|
237
|
+
this.expected = args.expected;
|
|
238
|
+
this.hint = args.hint;
|
|
239
|
+
this.detail = args.detail;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
var NetError = NetErrorClass;
|
|
243
|
+
|
|
244
|
+
// src/replication/codec.ts
|
|
245
|
+
var TYPED_ARRAYS = {
|
|
246
|
+
Float32Array,
|
|
247
|
+
Float64Array,
|
|
248
|
+
Int8Array,
|
|
249
|
+
Int16Array,
|
|
250
|
+
Int32Array,
|
|
251
|
+
Uint8Array,
|
|
252
|
+
Uint8ClampedArray,
|
|
253
|
+
Uint16Array,
|
|
254
|
+
Uint32Array
|
|
255
|
+
};
|
|
256
|
+
var PACKET_KINDS = [
|
|
257
|
+
"session-open",
|
|
258
|
+
"session-resume",
|
|
259
|
+
"baseline",
|
|
260
|
+
"delta",
|
|
261
|
+
"ack",
|
|
262
|
+
"rejection"
|
|
263
|
+
];
|
|
264
|
+
var REPLICATION_ENTITY_KINDS = [
|
|
265
|
+
"upsert",
|
|
266
|
+
"despawn"
|
|
267
|
+
];
|
|
268
|
+
function isPacketKind(value) {
|
|
269
|
+
return PACKET_KINDS.some((kind) => kind === value);
|
|
270
|
+
}
|
|
271
|
+
function isReplicationEntityKind(value) {
|
|
272
|
+
return REPLICATION_ENTITY_KINDS.some((kind) => kind === value);
|
|
273
|
+
}
|
|
274
|
+
function isSafeNonNegativeInteger(value) {
|
|
275
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
276
|
+
}
|
|
277
|
+
function isSessionId(value) {
|
|
278
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
279
|
+
}
|
|
280
|
+
function typedArrayName(value) {
|
|
281
|
+
for (const [name, typedArrayConstructor] of Object.entries(TYPED_ARRAYS)) {
|
|
282
|
+
if (value instanceof typedArrayConstructor) return name;
|
|
283
|
+
}
|
|
284
|
+
return void 0;
|
|
285
|
+
}
|
|
286
|
+
function canonicalize(value) {
|
|
287
|
+
const name = typedArrayName(value);
|
|
288
|
+
if (name !== void 0)
|
|
289
|
+
return { $typedArray: name, values: Array.from(value) };
|
|
290
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
291
|
+
if (value !== null && typeof value === "object")
|
|
292
|
+
return Object.fromEntries(
|
|
293
|
+
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])
|
|
294
|
+
);
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
function reviveTypedArrays(value) {
|
|
298
|
+
if (Array.isArray(value)) {
|
|
299
|
+
const values = [];
|
|
300
|
+
for (const item of value) {
|
|
301
|
+
const revived2 = reviveTypedArrays(item);
|
|
302
|
+
if ("reason" in revived2) return revived2;
|
|
303
|
+
values.push(revived2.value);
|
|
304
|
+
}
|
|
305
|
+
return { value: values };
|
|
306
|
+
}
|
|
307
|
+
if (value === null || typeof value !== "object") return { value };
|
|
308
|
+
const record = value;
|
|
309
|
+
if ("$typedArray" in record) {
|
|
310
|
+
if (Object.keys(record).length !== 2 || typeof record.$typedArray !== "string" || !Array.isArray(record.values))
|
|
311
|
+
return { reason: "typed-array tag must contain only an allowlisted name and values array" };
|
|
312
|
+
const typedArrayConstructor = TYPED_ARRAYS[record.$typedArray];
|
|
313
|
+
if (typedArrayConstructor === void 0 || record.values.some((item) => typeof item !== "number"))
|
|
314
|
+
return { reason: "typed-array tag contains an unsupported type or non-numeric value" };
|
|
315
|
+
return { value: new typedArrayConstructor(record.values) };
|
|
316
|
+
}
|
|
317
|
+
const revived = {};
|
|
318
|
+
for (const [key, item] of Object.entries(record)) {
|
|
319
|
+
const nested = reviveTypedArrays(item);
|
|
320
|
+
if ("reason" in nested) return nested;
|
|
321
|
+
revived[key] = nested.value;
|
|
322
|
+
}
|
|
323
|
+
return { value: revived };
|
|
324
|
+
}
|
|
325
|
+
function limitError(limit, actual, maximum) {
|
|
326
|
+
return new NetError({
|
|
327
|
+
code: "decode-limit-exceeded",
|
|
328
|
+
expected: `${limit} must not exceed ${maximum}`,
|
|
329
|
+
hint: "reduce the replicated payload or configure matching declared limits",
|
|
330
|
+
detail: { limit, actual, maximum }
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
function invalid(reason) {
|
|
334
|
+
return new NetError({
|
|
335
|
+
code: "decode-invalid-payload",
|
|
336
|
+
expected: `a version ${REPLICATION_PROTOCOL_VERSION} ${REPLICATION_PROTOCOL_PREFIX} packet`,
|
|
337
|
+
hint: "send bytes produced by the protocol-v2 replication codec",
|
|
338
|
+
detail: { reason }
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
function validateEntities(entities) {
|
|
342
|
+
const ids = /* @__PURE__ */ new Set();
|
|
343
|
+
for (const [entityIndex, entity] of entities.entries()) {
|
|
344
|
+
if (entity === null || typeof entity !== "object" || !isSafeNonNegativeInteger(entity.id) || !isReplicationEntityKind(entity.kind) || !Array.isArray(entity.components) || ids.has(entity.id))
|
|
345
|
+
return `entity record ${entityIndex} has an invalid or duplicate identity`;
|
|
346
|
+
ids.add(entity.id);
|
|
347
|
+
for (const [componentIndex, component] of entity.components.entries()) {
|
|
348
|
+
if (component === null || typeof component !== "object" || typeof component.name !== "string" || component.name.length === 0 || component.operation !== void 0 && component.operation !== "replace" && component.operation !== "remove" || component.data === null || typeof component.data !== "object" || Array.isArray(component.data) || component.operation === "remove" && Object.keys(component.data).length !== 0)
|
|
349
|
+
return `component record ${entityIndex}:${componentIndex} has invalid fields`;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return void 0;
|
|
353
|
+
}
|
|
354
|
+
function validatePacket(packet) {
|
|
355
|
+
if (packet.version !== REPLICATION_PROTOCOL_VERSION)
|
|
356
|
+
return "packet protocol version is unsupported";
|
|
357
|
+
if (!isPacketKind(packet.kind)) return "packet kind is unsupported";
|
|
358
|
+
if (!isSessionId(packet.sessionId)) return "sessionId must be a positive safe integer";
|
|
359
|
+
if (!isSafeNonNegativeInteger(packet.epoch)) return "epoch must be a non-negative safe integer";
|
|
360
|
+
if (packet.kind === "session-open" || packet.kind === "session-resume")
|
|
361
|
+
return packet.sequence === 0 ? void 0 : "session control sequence must be zero";
|
|
362
|
+
if (packet.kind === "ack")
|
|
363
|
+
return isSafeNonNegativeInteger(packet.acknowledgedSequence) ? void 0 : "acknowledgedSequence must be a non-negative safe integer";
|
|
364
|
+
if (!isSafeNonNegativeInteger(packet.sequence) || packet.sequence === 0)
|
|
365
|
+
return "sequence must be a positive safe integer";
|
|
366
|
+
if (packet.kind === "baseline" && packet.sequence !== 1) return "baseline sequence must be one";
|
|
367
|
+
if (packet.kind === "rejection") {
|
|
368
|
+
if (!isPacketKind(packet.rejectedKind) || typeof packet.reason !== "string")
|
|
369
|
+
return "rejection details are invalid";
|
|
370
|
+
return void 0;
|
|
371
|
+
}
|
|
372
|
+
if (packet.kind !== "baseline" && packet.kind !== "delta")
|
|
373
|
+
return "packet kind does not carry a data payload";
|
|
374
|
+
if (typeof packet.tick !== "number" || !Number.isSafeInteger(packet.tick))
|
|
375
|
+
return "tick must be a safe integer";
|
|
376
|
+
if (typeof packet.fingerprint !== "string") return "fingerprint must be a string";
|
|
377
|
+
return validateEntities(packet.entities);
|
|
378
|
+
}
|
|
379
|
+
function validateLimits(packet, bytes, limits) {
|
|
380
|
+
if (bytes !== void 0 && bytes.byteLength > limits.maxMessageBytes)
|
|
381
|
+
return limitError("maxMessageBytes", bytes.byteLength, limits.maxMessageBytes);
|
|
382
|
+
if (packet.entities.length > limits.maxEntities)
|
|
383
|
+
return limitError("maxEntities", packet.entities.length, limits.maxEntities);
|
|
384
|
+
let operations = 0;
|
|
385
|
+
const visit = (value) => {
|
|
386
|
+
if (typeof value === "string" && new TextEncoder().encode(value).byteLength > limits.maxStringBytes)
|
|
387
|
+
return limitError(
|
|
388
|
+
"maxStringBytes",
|
|
389
|
+
new TextEncoder().encode(value).byteLength,
|
|
390
|
+
limits.maxStringBytes
|
|
391
|
+
);
|
|
392
|
+
const typedArray = typedArrayName(value);
|
|
393
|
+
if (typedArray !== void 0) {
|
|
394
|
+
const contents = value;
|
|
395
|
+
if (contents.byteLength > limits.maxBufferBytes)
|
|
396
|
+
return limitError("maxBufferBytes", contents.byteLength, limits.maxBufferBytes);
|
|
397
|
+
if (contents.length > limits.maxArrayElements)
|
|
398
|
+
return limitError("maxArrayElements", contents.length, limits.maxArrayElements);
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
if (Array.isArray(value)) {
|
|
402
|
+
if (value.length > limits.maxArrayElements)
|
|
403
|
+
return limitError("maxArrayElements", value.length, limits.maxArrayElements);
|
|
404
|
+
for (const item of value) {
|
|
405
|
+
const problem = visit(item);
|
|
406
|
+
if (problem) return problem;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (value !== null && typeof value === "object" && !(value instanceof Uint8Array))
|
|
410
|
+
for (const item of Object.values(value)) {
|
|
411
|
+
const problem = visit(item);
|
|
412
|
+
if (problem) return problem;
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
};
|
|
416
|
+
for (const entity of packet.entities) {
|
|
417
|
+
operations += entity.components.length;
|
|
418
|
+
for (const component of entity.components) {
|
|
419
|
+
const problem = visit(component.data);
|
|
420
|
+
if (problem) return problem;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return operations > limits.maxComponentOperations ? limitError("maxComponentOperations", operations, limits.maxComponentOperations) : null;
|
|
424
|
+
}
|
|
425
|
+
function parse(bytes) {
|
|
426
|
+
const text = new TextDecoder().decode(bytes);
|
|
427
|
+
const separator = text.indexOf("\n");
|
|
428
|
+
if (separator < 0 || text.slice(0, separator) !== REPLICATION_PROTOCOL_PREFIX)
|
|
429
|
+
return { error: invalid("packet prefix does not match protocol-v2") };
|
|
430
|
+
try {
|
|
431
|
+
const decoded = JSON.parse(text.slice(separator + 1));
|
|
432
|
+
const revived = reviveTypedArrays(decoded);
|
|
433
|
+
if ("reason" in revived) return { error: invalid(revived.reason) };
|
|
434
|
+
if (revived.value === null || typeof revived.value !== "object")
|
|
435
|
+
return { error: invalid("packet must be an object") };
|
|
436
|
+
const packet = revived.value;
|
|
437
|
+
const reason = validatePacket(packet);
|
|
438
|
+
if (reason !== void 0) {
|
|
439
|
+
if (typeof packet.version === "number" && packet.version !== REPLICATION_PROTOCOL_VERSION)
|
|
440
|
+
return {
|
|
441
|
+
error: new NetError({
|
|
442
|
+
code: "protocol-unsupported-version",
|
|
443
|
+
expected: `protocol version ${REPLICATION_PROTOCOL_VERSION}`,
|
|
444
|
+
hint: "upgrade the peer before sending replicated bytes",
|
|
445
|
+
detail: {
|
|
446
|
+
receivedVersion: packet.version,
|
|
447
|
+
supportedVersion: REPLICATION_PROTOCOL_VERSION
|
|
448
|
+
}
|
|
449
|
+
})
|
|
450
|
+
};
|
|
451
|
+
return { error: invalid(reason) };
|
|
452
|
+
}
|
|
453
|
+
return { packet };
|
|
454
|
+
} catch {
|
|
455
|
+
return { error: invalid("payload is not valid JSON") };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function isDataPacket(packet) {
|
|
459
|
+
return packet.kind === "baseline" || packet.kind === "delta";
|
|
460
|
+
}
|
|
461
|
+
function encodeReplicationPacket(packet, limits) {
|
|
462
|
+
const reason = validatePacket(packet);
|
|
463
|
+
if (reason !== void 0) return err(invalid(reason));
|
|
464
|
+
const body = JSON.stringify(canonicalize(packet));
|
|
465
|
+
const bytes = new TextEncoder().encode(`${REPLICATION_PROTOCOL_PREFIX}
|
|
466
|
+
${body}`);
|
|
467
|
+
const failure = isDataPacket(packet) ? validateLimits(packet, bytes, limits) : null;
|
|
468
|
+
return failure ? err(failure) : ok(bytes);
|
|
469
|
+
}
|
|
470
|
+
function decodeReplicationPacket(bytes, limits) {
|
|
471
|
+
if (bytes.byteLength > limits.maxMessageBytes)
|
|
472
|
+
return err(limitError("maxMessageBytes", bytes.byteLength, limits.maxMessageBytes));
|
|
473
|
+
const parsed = parse(bytes);
|
|
474
|
+
if ("error" in parsed) return err(parsed.error);
|
|
475
|
+
const failure = isDataPacket(parsed.packet) ? validateLimits(parsed.packet, bytes, limits) : null;
|
|
476
|
+
return failure ? err(failure) : ok(parsed.packet);
|
|
477
|
+
}
|
|
478
|
+
var DEFAULT_REPLICATION_LIMITS = {
|
|
479
|
+
maxMessageBytes: 64 * 1024,
|
|
480
|
+
maxEntities: 1024,
|
|
481
|
+
maxComponentOperations: 4096,
|
|
482
|
+
maxStringBytes: 4096,
|
|
483
|
+
maxBufferBytes: 16 * 1024,
|
|
484
|
+
maxArrayElements: 1024
|
|
485
|
+
};
|
|
486
|
+
function hash(text) {
|
|
487
|
+
let value = 2166136261;
|
|
488
|
+
for (const char of text) {
|
|
489
|
+
value ^= char.charCodeAt(0);
|
|
490
|
+
value = Math.imul(value, 16777619);
|
|
491
|
+
}
|
|
492
|
+
return (value >>> 0).toString(16).padStart(8, "0");
|
|
493
|
+
}
|
|
494
|
+
function immutableProfile(options, limits, fingerprint) {
|
|
495
|
+
const entities = Object.freeze({
|
|
496
|
+
with: Object.freeze([...options.entities.with]),
|
|
497
|
+
...options.entities.without === void 0 ? {} : { without: Object.freeze([...options.entities.without]) }
|
|
498
|
+
});
|
|
499
|
+
return Object.freeze({
|
|
500
|
+
name: options.name,
|
|
501
|
+
entities,
|
|
502
|
+
components: Object.freeze([...options.components]),
|
|
503
|
+
limits: Object.freeze({ ...limits }),
|
|
504
|
+
fingerprint
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
function defineReplication(options) {
|
|
508
|
+
const portable = validateProfileComponents(options.components);
|
|
509
|
+
if (!portable.valid) {
|
|
510
|
+
const first = portable.errors[0];
|
|
511
|
+
if (first === void 0) {
|
|
512
|
+
return err(
|
|
513
|
+
new NetError({
|
|
514
|
+
code: "schema-invalid",
|
|
515
|
+
expected: "portable replication components",
|
|
516
|
+
hint: "select only components accepted by the ECS externalization kernel",
|
|
517
|
+
detail: { component: "", reason: "portable validation failed without a diagnostic" }
|
|
518
|
+
})
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
return err(
|
|
522
|
+
new NetError({
|
|
523
|
+
code: "schema-invalid",
|
|
524
|
+
expected: first.expected,
|
|
525
|
+
hint: first.hint,
|
|
526
|
+
detail: { component: first.component, reason: first.code }
|
|
527
|
+
})
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
const limits = { ...DEFAULT_REPLICATION_LIMITS, ...options.limits };
|
|
531
|
+
const signature = JSON.stringify({
|
|
532
|
+
name: options.name,
|
|
533
|
+
query: {
|
|
534
|
+
with: options.entities.with.map((component) => component.name),
|
|
535
|
+
...options.entities.without === void 0 ? {} : { without: options.entities.without.map((component) => component.name) }
|
|
536
|
+
},
|
|
537
|
+
components: options.components.map((component) => ({
|
|
538
|
+
name: component.name,
|
|
539
|
+
schema: componentSchema(component)
|
|
540
|
+
})),
|
|
541
|
+
limits
|
|
542
|
+
});
|
|
543
|
+
return ok(immutableProfile(options, limits, hash(signature)));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// src/replication/authority.ts
|
|
547
|
+
function stable(value) {
|
|
548
|
+
return JSON.stringify(value);
|
|
549
|
+
}
|
|
550
|
+
var AuthorityCoordinator = class {
|
|
551
|
+
#world;
|
|
552
|
+
#profile;
|
|
553
|
+
#ids = /* @__PURE__ */ new Map();
|
|
554
|
+
#known = /* @__PURE__ */ new Map();
|
|
555
|
+
#nextId = 1;
|
|
556
|
+
#tick = 0;
|
|
557
|
+
#epoch = 0;
|
|
558
|
+
#sequence = 0;
|
|
559
|
+
#sessionId;
|
|
560
|
+
constructor(world, profile, sessionId = 1) {
|
|
561
|
+
this.#world = world;
|
|
562
|
+
this.#profile = profile;
|
|
563
|
+
this.#sessionId = sessionId;
|
|
564
|
+
}
|
|
565
|
+
idFor(entity) {
|
|
566
|
+
return this.#ids.get(entity) ?? 0;
|
|
567
|
+
}
|
|
568
|
+
publish() {
|
|
569
|
+
return this.#publish(false);
|
|
570
|
+
}
|
|
571
|
+
publishFull() {
|
|
572
|
+
return this.#publish(true);
|
|
573
|
+
}
|
|
574
|
+
nextPublicationEpoch(forceFull = false) {
|
|
575
|
+
return forceFull && this.#tick > 0 ? this.#epoch + 1 : this.#epoch;
|
|
576
|
+
}
|
|
577
|
+
#publish(forceFull) {
|
|
578
|
+
const candidateIds = new Map(this.#ids);
|
|
579
|
+
let candidateNextId = this.#nextId;
|
|
580
|
+
const current = /* @__PURE__ */ new Map();
|
|
581
|
+
const query = this.#world.query(this.#profile.entities).unwrap();
|
|
582
|
+
for (const row of query) {
|
|
583
|
+
if (!candidateIds.has(row.entity)) candidateIds.set(row.entity, candidateNextId++);
|
|
584
|
+
}
|
|
585
|
+
for (const row of query) {
|
|
586
|
+
const entity = row.entity;
|
|
587
|
+
const components = [];
|
|
588
|
+
for (const component of this.#profile.components) {
|
|
589
|
+
const raw = this.#world.get(entity, component);
|
|
590
|
+
if (raw.ok) {
|
|
591
|
+
components.push({
|
|
592
|
+
name: component.name,
|
|
593
|
+
data: projectComponentData(
|
|
594
|
+
component,
|
|
595
|
+
raw.value,
|
|
596
|
+
(reference) => candidateIds.get(reference) ?? 0
|
|
597
|
+
)
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const id = candidateIds.get(entity);
|
|
602
|
+
if (id !== void 0) current.set(entity, { id, components });
|
|
603
|
+
}
|
|
604
|
+
const full = forceFull || this.#tick === 0;
|
|
605
|
+
let nextEpoch = this.#epoch;
|
|
606
|
+
let nextSequence = this.#sequence;
|
|
607
|
+
if (forceFull && this.#tick > 0) {
|
|
608
|
+
nextEpoch += 1;
|
|
609
|
+
nextSequence = 0;
|
|
610
|
+
}
|
|
611
|
+
if (full && nextSequence === 0) nextSequence = 1;
|
|
612
|
+
else nextSequence += 1;
|
|
613
|
+
const entities = [];
|
|
614
|
+
for (const [entity, entry] of current) {
|
|
615
|
+
const prior = this.#known.get(entity);
|
|
616
|
+
const components = full || prior === void 0 ? entry.components : [
|
|
617
|
+
...entry.components.filter(
|
|
618
|
+
(component) => prior.components.get(component.name) !== stable(component.data)
|
|
619
|
+
),
|
|
620
|
+
...[...prior.components.keys()].filter((name) => !entry.components.some((component) => component.name === name)).map((name) => ({ name, operation: "remove", data: {} }))
|
|
621
|
+
];
|
|
622
|
+
if (full || prior === void 0 || components.length > 0)
|
|
623
|
+
entities.push({ id: entry.id, kind: "upsert", components });
|
|
624
|
+
}
|
|
625
|
+
if (!full)
|
|
626
|
+
for (const [entity, prior] of this.#known) {
|
|
627
|
+
if (!current.has(entity)) entities.push({ id: prior.id, kind: "despawn", components: [] });
|
|
628
|
+
}
|
|
629
|
+
const candidateKnown = /* @__PURE__ */ new Map();
|
|
630
|
+
for (const [entity, entry] of current) {
|
|
631
|
+
candidateKnown.set(entity, {
|
|
632
|
+
id: entry.id,
|
|
633
|
+
components: new Map(
|
|
634
|
+
entry.components.map((component) => [component.name, stable(component.data)])
|
|
635
|
+
)
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
for (const [entity] of candidateIds) {
|
|
639
|
+
if (!current.has(entity)) candidateIds.delete(entity);
|
|
640
|
+
}
|
|
641
|
+
const packet = full ? {
|
|
642
|
+
version: REPLICATION_PROTOCOL_VERSION,
|
|
643
|
+
kind: "baseline",
|
|
644
|
+
sessionId: this.#sessionId,
|
|
645
|
+
epoch: nextEpoch,
|
|
646
|
+
sequence: nextSequence,
|
|
647
|
+
fingerprint: this.#profile.fingerprint,
|
|
648
|
+
tick: this.#tick + 1,
|
|
649
|
+
entities
|
|
650
|
+
} : {
|
|
651
|
+
version: REPLICATION_PROTOCOL_VERSION,
|
|
652
|
+
kind: "delta",
|
|
653
|
+
sessionId: this.#sessionId,
|
|
654
|
+
epoch: nextEpoch,
|
|
655
|
+
sequence: nextSequence,
|
|
656
|
+
fingerprint: this.#profile.fingerprint,
|
|
657
|
+
tick: this.#tick + 1,
|
|
658
|
+
entities
|
|
659
|
+
};
|
|
660
|
+
const encoded = encodeReplicationPacket(
|
|
661
|
+
packet,
|
|
662
|
+
this.#profile.limits ?? DEFAULT_REPLICATION_LIMITS
|
|
663
|
+
);
|
|
664
|
+
if (!encoded.ok) return err(encoded.error);
|
|
665
|
+
this.#ids.clear();
|
|
666
|
+
for (const [entity, id] of candidateIds) this.#ids.set(entity, id);
|
|
667
|
+
this.#known.clear();
|
|
668
|
+
for (const [entity, known] of candidateKnown) this.#known.set(entity, known);
|
|
669
|
+
this.#nextId = candidateNextId;
|
|
670
|
+
this.#tick = packet.tick;
|
|
671
|
+
this.#epoch = nextEpoch;
|
|
672
|
+
this.#sequence = nextSequence;
|
|
673
|
+
return ok({ ...packet, bytes: encoded.value });
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
function createAuthorityCoordinator(world, profile) {
|
|
677
|
+
return new AuthorityCoordinator(world, profile);
|
|
678
|
+
}
|
|
679
|
+
function validateHandshake(local, remote) {
|
|
680
|
+
if (local.fingerprint !== remote.fingerprint)
|
|
681
|
+
return err(
|
|
682
|
+
new NetError({
|
|
683
|
+
code: "handshake-profile-mismatch",
|
|
684
|
+
expected: "matching protocol, profile, and declared limits",
|
|
685
|
+
hint: "use identical ordered replication components and limits on both peers",
|
|
686
|
+
detail: { localFingerprint: local.fingerprint, remoteFingerprint: remote.fingerprint }
|
|
687
|
+
})
|
|
688
|
+
);
|
|
689
|
+
return ok(void 0);
|
|
690
|
+
}
|
|
691
|
+
var ReplicaCoordinator = class {
|
|
692
|
+
#world;
|
|
693
|
+
#profile;
|
|
694
|
+
#entities = /* @__PURE__ */ new Map();
|
|
695
|
+
#lastTick = 0;
|
|
696
|
+
#epoch = -1;
|
|
697
|
+
#lastSequence = 0;
|
|
698
|
+
#lastPacketOutcome = "accepted";
|
|
699
|
+
#stopped = false;
|
|
700
|
+
constructor(world, profile, _endpoint) {
|
|
701
|
+
this.#world = world;
|
|
702
|
+
this.#profile = profile;
|
|
703
|
+
}
|
|
704
|
+
entityFor(id) {
|
|
705
|
+
return this.#entities.get(id);
|
|
706
|
+
}
|
|
707
|
+
readComponent(id, component) {
|
|
708
|
+
const entity = this.#entities.get(id);
|
|
709
|
+
if (entity === void 0) return void 0;
|
|
710
|
+
const read = this.#world.get(entity, component);
|
|
711
|
+
return read.ok ? read.value : void 0;
|
|
712
|
+
}
|
|
713
|
+
snapshot() {
|
|
714
|
+
return [...this.#entities].map(([id, entity]) => ({
|
|
715
|
+
id,
|
|
716
|
+
components: this.#profile.components.filter((component) => this.#world.get(entity, component).ok).map((component) => component.name)
|
|
717
|
+
})).sort((a, b) => a.id - b.id);
|
|
718
|
+
}
|
|
719
|
+
disconnect() {
|
|
720
|
+
}
|
|
721
|
+
/** Remove the last replica baseline when the authority connection closes. */
|
|
722
|
+
clear() {
|
|
723
|
+
for (const entity of this.#entities.values()) this.#world.despawn(entity).unwrap();
|
|
724
|
+
this.#entities.clear();
|
|
725
|
+
}
|
|
726
|
+
get stopped() {
|
|
727
|
+
return this.#stopped;
|
|
728
|
+
}
|
|
729
|
+
get tick() {
|
|
730
|
+
return this.#lastTick;
|
|
731
|
+
}
|
|
732
|
+
/** Report the last accepted, duplicate, or stale-epoch packet decision. */
|
|
733
|
+
get lastPacketOutcome() {
|
|
734
|
+
return this.#lastPacketOutcome;
|
|
735
|
+
}
|
|
736
|
+
getPendingUnresolvedReferences() {
|
|
737
|
+
return 0;
|
|
738
|
+
}
|
|
739
|
+
#entityReferences(value) {
|
|
740
|
+
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
|
|
741
|
+
return Array.from(value);
|
|
742
|
+
}
|
|
743
|
+
return [];
|
|
744
|
+
}
|
|
745
|
+
validate(packet) {
|
|
746
|
+
this.#lastPacketOutcome = "accepted";
|
|
747
|
+
if (this.#stopped)
|
|
748
|
+
return new NetError({
|
|
749
|
+
code: "apply-invariant-failed",
|
|
750
|
+
expected: "an active replica coordinator",
|
|
751
|
+
hint: "create a new session after a fatal apply failure",
|
|
752
|
+
detail: { reason: "replication stopped" }
|
|
753
|
+
});
|
|
754
|
+
if (packet.fingerprint !== this.#profile.fingerprint)
|
|
755
|
+
return new NetError({
|
|
756
|
+
code: "schema-invalid",
|
|
757
|
+
expected: "a batch for the negotiated replication profile",
|
|
758
|
+
hint: "complete handshake before applying replication bytes",
|
|
759
|
+
detail: { component: "", reason: "fingerprint mismatch" }
|
|
760
|
+
});
|
|
761
|
+
const newEpoch = packet.epoch > this.#epoch;
|
|
762
|
+
if (this.#epoch < 0 && packet.kind !== "baseline")
|
|
763
|
+
return new NetError({
|
|
764
|
+
code: "session-illegal-transition",
|
|
765
|
+
expected: "a baseline before any delta in a session epoch",
|
|
766
|
+
hint: "accept a complete authoritative baseline before applying deltas",
|
|
767
|
+
detail: { from: "connecting", to: packet.kind }
|
|
768
|
+
});
|
|
769
|
+
if (packet.epoch > this.#epoch && (packet.kind !== "baseline" || packet.sequence !== 1))
|
|
770
|
+
return new NetError({
|
|
771
|
+
code: "session-illegal-transition",
|
|
772
|
+
expected: "a sequence-one baseline at the start of a new epoch",
|
|
773
|
+
hint: "request a fresh baseline before applying the next delta",
|
|
774
|
+
detail: { from: "resyncing", to: packet.kind }
|
|
775
|
+
});
|
|
776
|
+
if (packet.epoch < this.#epoch) return null;
|
|
777
|
+
if (packet.kind === "baseline" && !newEpoch && this.#lastSequence >= 1) {
|
|
778
|
+
this.#lastPacketOutcome = "duplicate";
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
if (packet.kind === "delta" && packet.sequence <= this.#lastSequence) {
|
|
782
|
+
this.#lastPacketOutcome = "duplicate";
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
if (packet.kind === "delta" && packet.sequence !== this.#lastSequence + 1)
|
|
786
|
+
return new NetError({
|
|
787
|
+
code: "ordering-invalid-tick",
|
|
788
|
+
expected: "the next contiguous replication sequence",
|
|
789
|
+
hint: "request a fresh baseline when a sequence gap is detected",
|
|
790
|
+
detail: { receivedTick: packet.sequence, lastTick: this.#lastSequence }
|
|
791
|
+
});
|
|
792
|
+
if (!newEpoch && packet.tick <= this.#lastTick)
|
|
793
|
+
return new NetError({
|
|
794
|
+
code: "ordering-invalid-tick",
|
|
795
|
+
expected: "a strictly monotonic authority tick",
|
|
796
|
+
hint: "discard duplicate, stale, and out-of-order batches",
|
|
797
|
+
detail: { receivedTick: packet.tick, lastTick: this.#lastTick }
|
|
798
|
+
});
|
|
799
|
+
const batchIds = /* @__PURE__ */ new Set();
|
|
800
|
+
const knownIds = newEpoch ? /* @__PURE__ */ new Set() : new Set(this.#entities.keys());
|
|
801
|
+
for (const record of packet.entities) {
|
|
802
|
+
if (!Number.isSafeInteger(record.id) || record.id <= 0 || batchIds.has(record.id))
|
|
803
|
+
return new NetError({
|
|
804
|
+
code: "identity-invalid",
|
|
805
|
+
expected: "unique non-zero NetEntityId values",
|
|
806
|
+
hint: "use session-issued identity values exactly once per batch",
|
|
807
|
+
detail: { id: record.id, reason: "zero, invalid, or duplicate identity" }
|
|
808
|
+
});
|
|
809
|
+
batchIds.add(record.id);
|
|
810
|
+
}
|
|
811
|
+
for (const record of packet.entities) {
|
|
812
|
+
if (record.kind === "despawn" && !knownIds.has(record.id))
|
|
813
|
+
return new NetError({
|
|
814
|
+
code: "identity-invalid",
|
|
815
|
+
expected: "a known identity for despawn",
|
|
816
|
+
hint: "do not reuse or despawn unknown network identities",
|
|
817
|
+
detail: { id: record.id, reason: "unknown identity" }
|
|
818
|
+
});
|
|
819
|
+
for (const entry of record.components) {
|
|
820
|
+
const component = this.#profile.components.find(
|
|
821
|
+
(candidate) => candidate.name === entry.name
|
|
822
|
+
);
|
|
823
|
+
if (component === void 0)
|
|
824
|
+
return new NetError({
|
|
825
|
+
code: "schema-invalid",
|
|
826
|
+
expected: "a component selected by the negotiated profile",
|
|
827
|
+
hint: "send only components from the ordered replication profile",
|
|
828
|
+
detail: { component: entry.name, reason: "unselected component" }
|
|
829
|
+
});
|
|
830
|
+
if (entry.operation === "remove") continue;
|
|
831
|
+
for (const [field, value] of Object.entries(entry.data)) {
|
|
832
|
+
if (!(field in componentSchema(component)))
|
|
833
|
+
return new NetError({
|
|
834
|
+
code: "schema-invalid",
|
|
835
|
+
expected: "component fields declared by the negotiated ECS schema",
|
|
836
|
+
hint: "send only fields declared by the replicated component token",
|
|
837
|
+
detail: { component: entry.name, reason: `unknown field ${field}` }
|
|
838
|
+
});
|
|
839
|
+
const kind = classifyEntityField(component, field);
|
|
840
|
+
const refs = kind?.isArray ? this.#entityReferences(value) : kind ? [value] : [];
|
|
841
|
+
for (const reference of refs)
|
|
842
|
+
if (reference !== null && (typeof reference !== "number" || reference === 0 || !knownIds.has(reference) && !batchIds.has(reference)))
|
|
843
|
+
return new NetError({
|
|
844
|
+
code: "remap-unresolved-reference",
|
|
845
|
+
expected: "every entity reference to resolve in the current or same batch",
|
|
846
|
+
hint: "include the referenced spawn in this batch; cross-batch pending references are unsupported",
|
|
847
|
+
detail: { id: record.id, referencedId: Number(reference) }
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
apply(packet) {
|
|
855
|
+
const failure = this.validate(packet);
|
|
856
|
+
if (failure) {
|
|
857
|
+
return err(failure);
|
|
858
|
+
}
|
|
859
|
+
if (packet.epoch < this.#epoch) {
|
|
860
|
+
this.#lastPacketOutcome = "ignored-old-epoch";
|
|
861
|
+
return ok(void 0);
|
|
862
|
+
}
|
|
863
|
+
if (this.#lastPacketOutcome === "duplicate") return ok(void 0);
|
|
864
|
+
const replacingEpoch = packet.epoch > this.#epoch;
|
|
865
|
+
try {
|
|
866
|
+
if (replacingEpoch) {
|
|
867
|
+
for (const entity of this.#entities.values()) this.#world.despawn(entity).unwrap();
|
|
868
|
+
this.#entities.clear();
|
|
869
|
+
}
|
|
870
|
+
for (const record of packet.entities)
|
|
871
|
+
if (record.kind === "upsert" && !this.#entities.has(record.id))
|
|
872
|
+
this.#entities.set(record.id, this.#world.spawn().unwrap());
|
|
873
|
+
for (const record of packet.entities)
|
|
874
|
+
if (record.kind === "upsert") {
|
|
875
|
+
const entity = this.#entities.get(record.id);
|
|
876
|
+
if (entity === void 0) throw new Error(`missing allocated entity ${record.id}`);
|
|
877
|
+
for (const entry of record.components) {
|
|
878
|
+
const component = this.#profile.components.find(
|
|
879
|
+
(candidate) => candidate.name === entry.name
|
|
880
|
+
);
|
|
881
|
+
if (component === void 0) throw new Error(`missing profile component ${entry.name}`);
|
|
882
|
+
if (entry.operation === "remove") {
|
|
883
|
+
const removal = this.#world.removeComponent(entity, component);
|
|
884
|
+
if (!removal.ok) throw removal.error;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
const data = Object.fromEntries(
|
|
888
|
+
Object.entries(entry.data).map(([field, value]) => {
|
|
889
|
+
const kind = classifyEntityField(component, field);
|
|
890
|
+
if (kind === null) return [field, value];
|
|
891
|
+
const mapped = kind.isArray ? this.#entityReferences(value).map((id) => {
|
|
892
|
+
if (id === null) return null;
|
|
893
|
+
const reference = this.#entities.get(id);
|
|
894
|
+
if (reference === void 0)
|
|
895
|
+
throw new Error(`missing entity reference ${id}`);
|
|
896
|
+
return reference;
|
|
897
|
+
}) : value === null ? null : this.#entities.get(value);
|
|
898
|
+
if (mapped === void 0) throw new Error(`missing entity reference ${value}`);
|
|
899
|
+
return [field, mapped];
|
|
900
|
+
})
|
|
901
|
+
);
|
|
902
|
+
const typedData = data;
|
|
903
|
+
const exists = this.#world.get(entity, component);
|
|
904
|
+
const write = exists.ok ? this.#world.set(entity, component, typedData) : this.#world.addComponent(entity, { component, data: typedData });
|
|
905
|
+
if (!write.ok) throw write.error;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
for (const record of packet.entities)
|
|
909
|
+
if (record.kind === "despawn") {
|
|
910
|
+
const entity = this.#entities.get(record.id);
|
|
911
|
+
if (entity === void 0) throw new Error(`missing despawn entity ${record.id}`);
|
|
912
|
+
this.#world.despawn(entity).unwrap();
|
|
913
|
+
this.#entities.delete(record.id);
|
|
914
|
+
}
|
|
915
|
+
this.#epoch = packet.epoch;
|
|
916
|
+
this.#lastSequence = packet.sequence;
|
|
917
|
+
this.#lastTick = packet.tick;
|
|
918
|
+
this.#lastPacketOutcome = "accepted";
|
|
919
|
+
return ok(void 0);
|
|
920
|
+
} catch (cause) {
|
|
921
|
+
this.#stopped = true;
|
|
922
|
+
return err(
|
|
923
|
+
new NetError({
|
|
924
|
+
code: "apply-invariant-failed",
|
|
925
|
+
expected: "ECS apply invariants to accept a validated batch",
|
|
926
|
+
hint: "stop this replication session and inspect the ECS error",
|
|
927
|
+
detail: { reason: cause instanceof Error ? cause.message : String(cause) }
|
|
928
|
+
})
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
function createReplicaCoordinator(world, profile, endpoint) {
|
|
934
|
+
return new ReplicaCoordinator(world, profile, endpoint);
|
|
935
|
+
}
|
|
936
|
+
function applyReplicationPacket(replica, packet) {
|
|
937
|
+
return replica.apply(packet);
|
|
938
|
+
}
|
|
939
|
+
function decodeAndApplyReplicationPacket(replica, bytes, limits) {
|
|
940
|
+
const decoded = decodeReplicationPacket(bytes, limits);
|
|
941
|
+
if (!decoded.ok) {
|
|
942
|
+
return err(decoded.error);
|
|
943
|
+
}
|
|
944
|
+
if (decoded.value.kind !== "baseline" && decoded.value.kind !== "delta") {
|
|
945
|
+
return err(
|
|
946
|
+
new NetError({
|
|
947
|
+
code: "decode-invalid-payload",
|
|
948
|
+
expected: "a baseline or delta replication packet",
|
|
949
|
+
hint: "apply only data packets through the replica coordinator",
|
|
950
|
+
detail: { reason: "control packet cannot be applied as ECS data" }
|
|
951
|
+
})
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
return replica.apply(decoded.value);
|
|
955
|
+
}
|
|
956
|
+
var DEFAULT_NET_RECOVERY_POLICY = Object.freeze({
|
|
957
|
+
maxSessions: 64,
|
|
958
|
+
maxPendingPackets: 32,
|
|
959
|
+
ackTimeoutMs: 250,
|
|
960
|
+
maxPacketRetries: 3,
|
|
961
|
+
maxReconnectAttempts: 5,
|
|
962
|
+
reconnectDeadlineMs: 1e4,
|
|
963
|
+
reconnectDelaysMs: Object.freeze([0, 100, 200, 400, 800])
|
|
964
|
+
});
|
|
965
|
+
function policyError(field, reason) {
|
|
966
|
+
return new NetError({
|
|
967
|
+
code: "recovery-policy-invalid",
|
|
968
|
+
expected: "finite positive recovery policy bounds",
|
|
969
|
+
hint: "provide positive safe integers and a finite non-negative delay sequence",
|
|
970
|
+
detail: { field, reason }
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
function isPositiveSafeInteger(value) {
|
|
974
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
975
|
+
}
|
|
976
|
+
function validateNetRecoveryPolicy(policy) {
|
|
977
|
+
const positiveFields = [
|
|
978
|
+
"maxSessions",
|
|
979
|
+
"maxPendingPackets",
|
|
980
|
+
"ackTimeoutMs",
|
|
981
|
+
"maxPacketRetries",
|
|
982
|
+
"maxReconnectAttempts",
|
|
983
|
+
"reconnectDeadlineMs"
|
|
984
|
+
];
|
|
985
|
+
for (const field of positiveFields) {
|
|
986
|
+
if (!isPositiveSafeInteger(policy[field]))
|
|
987
|
+
return err(policyError(field, "value must be a positive safe integer"));
|
|
988
|
+
}
|
|
989
|
+
if (!Array.isArray(policy.reconnectDelaysMs) || policy.reconnectDelaysMs.length === 0 || policy.reconnectDelaysMs.some((delay) => !Number.isSafeInteger(delay) || delay < 0))
|
|
990
|
+
return err(
|
|
991
|
+
policyError("reconnectDelaysMs", "values must be a non-empty finite delay sequence")
|
|
992
|
+
);
|
|
993
|
+
return ok(void 0);
|
|
994
|
+
}
|
|
995
|
+
function resolveNetRecoveryPolicy(overrides = {}) {
|
|
996
|
+
const policy = {
|
|
997
|
+
...DEFAULT_NET_RECOVERY_POLICY,
|
|
998
|
+
...overrides,
|
|
999
|
+
reconnectDelaysMs: overrides.reconnectDelaysMs === void 0 ? DEFAULT_NET_RECOVERY_POLICY.reconnectDelaysMs : [...overrides.reconnectDelaysMs]
|
|
1000
|
+
};
|
|
1001
|
+
const valid = validateNetRecoveryPolicy(policy);
|
|
1002
|
+
return valid.ok ? ok(Object.freeze(policy)) : err(valid.error);
|
|
1003
|
+
}
|
|
1004
|
+
function createSessionId(value) {
|
|
1005
|
+
if (!isPositiveSafeInteger(value))
|
|
1006
|
+
return err(
|
|
1007
|
+
new NetError({
|
|
1008
|
+
code: "recovery-policy-invalid",
|
|
1009
|
+
expected: "a positive safe integer SessionId",
|
|
1010
|
+
hint: "use the authority-issued application session identity",
|
|
1011
|
+
detail: { field: "sessionId", reason: "SessionId must be a positive safe integer" }
|
|
1012
|
+
})
|
|
1013
|
+
);
|
|
1014
|
+
return ok(value);
|
|
1015
|
+
}
|
|
1016
|
+
var LEGAL_TRANSITIONS = {
|
|
1017
|
+
connecting: ["recovering", "resyncing", "failed", "retired"],
|
|
1018
|
+
resyncing: ["active", "recovering", "failed", "retired"],
|
|
1019
|
+
active: ["active", "recovering", "failed", "retired"],
|
|
1020
|
+
recovering: ["recovering", "resyncing", "failed", "retired"],
|
|
1021
|
+
failed: ["retired"],
|
|
1022
|
+
retired: ["retired"]
|
|
1023
|
+
};
|
|
1024
|
+
function isLegalNetSessionTransition(from, to) {
|
|
1025
|
+
return LEGAL_TRANSITIONS[from].includes(to);
|
|
1026
|
+
}
|
|
1027
|
+
function transitionNetSessionState(from, to) {
|
|
1028
|
+
if (from.sessionId !== to.sessionId || !isLegalNetSessionTransition(from.kind, to.kind))
|
|
1029
|
+
return err(
|
|
1030
|
+
new NetError({
|
|
1031
|
+
code: "session-illegal-transition",
|
|
1032
|
+
expected: "a legal transition for the same SessionId",
|
|
1033
|
+
hint: "wait for the current session state or retire the session before replacing it",
|
|
1034
|
+
detail: { from: from.kind, to: to.kind }
|
|
1035
|
+
})
|
|
1036
|
+
);
|
|
1037
|
+
return ok(to);
|
|
1038
|
+
}
|
|
1039
|
+
var RECOVERY_ERROR_CODES = [
|
|
1040
|
+
"protocol-unsupported-version",
|
|
1041
|
+
"session-illegal-transition",
|
|
1042
|
+
"recovery-policy-invalid",
|
|
1043
|
+
"recovery-rejected",
|
|
1044
|
+
"recovery-exhausted"
|
|
1045
|
+
];
|
|
1046
|
+
|
|
1047
|
+
// src/session/net-session.ts
|
|
1048
|
+
var defaultClock = {
|
|
1049
|
+
now: () => Date.now(),
|
|
1050
|
+
schedule: (delayMs, callback) => {
|
|
1051
|
+
const id = globalThis.setTimeout(callback, delayMs);
|
|
1052
|
+
return { cancel: () => globalThis.clearTimeout(id) };
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
function recoveryFailure(reason) {
|
|
1056
|
+
return new NetError({
|
|
1057
|
+
code: "recovery-rejected",
|
|
1058
|
+
expected: "a recoverable NetSession lifecycle operation",
|
|
1059
|
+
hint: "inspect the current snapshot and retire the session after terminal failure",
|
|
1060
|
+
detail: { reason }
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
function initialState(sessionId, endpoint) {
|
|
1064
|
+
return endpoint === void 0 ? { kind: "connecting", sessionId } : { kind: "resyncing", sessionId, epoch: 0 };
|
|
1065
|
+
}
|
|
1066
|
+
var NetSession = class {
|
|
1067
|
+
#endpoint;
|
|
1068
|
+
#connector;
|
|
1069
|
+
#clock;
|
|
1070
|
+
#policy;
|
|
1071
|
+
#sessionId;
|
|
1072
|
+
#peerIds = /* @__PURE__ */ new Set();
|
|
1073
|
+
#sessionPeers = /* @__PURE__ */ new Map();
|
|
1074
|
+
#announcedPeers = /* @__PURE__ */ new Set();
|
|
1075
|
+
#sessionAnnounced = false;
|
|
1076
|
+
#rawMessages = [];
|
|
1077
|
+
#maxRawMessages;
|
|
1078
|
+
#authority;
|
|
1079
|
+
#pendingFullPeers = /* @__PURE__ */ new Set();
|
|
1080
|
+
#replica;
|
|
1081
|
+
#state;
|
|
1082
|
+
#lastError;
|
|
1083
|
+
#epoch = 0;
|
|
1084
|
+
#sequence = 0;
|
|
1085
|
+
#acknowledgedSequence = 0;
|
|
1086
|
+
#reconnectAttempts = 0;
|
|
1087
|
+
#pendingConnect;
|
|
1088
|
+
#retryTimer;
|
|
1089
|
+
#ledger = /* @__PURE__ */ new Map();
|
|
1090
|
+
#disposed = false;
|
|
1091
|
+
constructor(config) {
|
|
1092
|
+
this.#endpoint = config.endpoint;
|
|
1093
|
+
this.#connector = config.connector;
|
|
1094
|
+
this.#clock = config.clock ?? defaultClock;
|
|
1095
|
+
this.#maxRawMessages = config.maxRawMessages;
|
|
1096
|
+
const resolvedSessionId = this.#resolveSessionId(config.sessionId);
|
|
1097
|
+
this.#sessionId = resolvedSessionId.ok ? resolvedSessionId.value : 1;
|
|
1098
|
+
const policy = resolveNetRecoveryPolicy(config.recovery);
|
|
1099
|
+
this.#policy = policy.ok ? policy.value : DEFAULT_NET_RECOVERY_POLICY;
|
|
1100
|
+
this.#state = initialState(this.#sessionId, this.#endpoint);
|
|
1101
|
+
if (!resolvedSessionId.ok) this.#setFailure(resolvedSessionId.error);
|
|
1102
|
+
else if (!policy.ok) this.#setFailure(policy.error);
|
|
1103
|
+
}
|
|
1104
|
+
#resolveSessionId(value) {
|
|
1105
|
+
return createSessionId(value ?? 1);
|
|
1106
|
+
}
|
|
1107
|
+
#setState(next) {
|
|
1108
|
+
const transition = transitionNetSessionState(this.#state, next);
|
|
1109
|
+
if (transition.ok) {
|
|
1110
|
+
this.#state = transition.value;
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
this.#setFailure(transition.error);
|
|
1114
|
+
}
|
|
1115
|
+
#setFailure(failure) {
|
|
1116
|
+
this.#lastError = failure;
|
|
1117
|
+
if (this.#state.kind !== "failed" && this.#state.kind !== "retired")
|
|
1118
|
+
this.#setState({ kind: "failed", sessionId: this.#sessionId, error: failure });
|
|
1119
|
+
this.#authority = void 0;
|
|
1120
|
+
this.#peerIds.clear();
|
|
1121
|
+
this.#sessionPeers.clear();
|
|
1122
|
+
this.#announcedPeers.clear();
|
|
1123
|
+
this.#sessionAnnounced = false;
|
|
1124
|
+
this.#pendingFullPeers.clear();
|
|
1125
|
+
this.#rawMessages = [];
|
|
1126
|
+
this.#clearRecoveryWork();
|
|
1127
|
+
this.#endpoint?.close();
|
|
1128
|
+
}
|
|
1129
|
+
#clearRecoveryWork() {
|
|
1130
|
+
this.#retryTimer?.cancel();
|
|
1131
|
+
this.#retryTimer = void 0;
|
|
1132
|
+
this.#pendingConnect?.abort();
|
|
1133
|
+
this.#pendingConnect = void 0;
|
|
1134
|
+
this.#ledger.clear();
|
|
1135
|
+
}
|
|
1136
|
+
#beginRecovery() {
|
|
1137
|
+
const previousEndpoint = this.#endpoint;
|
|
1138
|
+
this.#endpoint = void 0;
|
|
1139
|
+
previousEndpoint?.close();
|
|
1140
|
+
if (this.#state.kind === "connecting" || this.#state.kind === "active" || this.#state.kind === "resyncing")
|
|
1141
|
+
this.#setState({
|
|
1142
|
+
kind: "recovering",
|
|
1143
|
+
sessionId: this.#sessionId,
|
|
1144
|
+
epoch: this.#epoch,
|
|
1145
|
+
attempt: 0
|
|
1146
|
+
});
|
|
1147
|
+
this.#ledger.clear();
|
|
1148
|
+
this.#sequence = 0;
|
|
1149
|
+
this.#acknowledgedSequence = 0;
|
|
1150
|
+
this.#peerIds.clear();
|
|
1151
|
+
this.#sessionPeers.clear();
|
|
1152
|
+
this.#announcedPeers.clear();
|
|
1153
|
+
this.#sessionAnnounced = false;
|
|
1154
|
+
this.#pendingFullPeers.clear();
|
|
1155
|
+
this.#rawMessages = [];
|
|
1156
|
+
}
|
|
1157
|
+
#attemptRecovery() {
|
|
1158
|
+
if (this.#disposed || this.#state.kind !== "recovering" || this.#pendingConnect) return;
|
|
1159
|
+
if (this.#reconnectAttempts >= this.#policy.maxReconnectAttempts) {
|
|
1160
|
+
this.#setFailure(
|
|
1161
|
+
new NetError({
|
|
1162
|
+
code: "recovery-exhausted",
|
|
1163
|
+
expected: "reconnect attempts within the configured finite bound",
|
|
1164
|
+
hint: "inspect the failure and create a new session after exhaustion",
|
|
1165
|
+
detail: {
|
|
1166
|
+
attempts: this.#reconnectAttempts,
|
|
1167
|
+
maxAttempts: this.#policy.maxReconnectAttempts
|
|
1168
|
+
}
|
|
1169
|
+
})
|
|
1170
|
+
);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
this.#reconnectAttempts += 1;
|
|
1174
|
+
this.#setState({
|
|
1175
|
+
kind: "recovering",
|
|
1176
|
+
sessionId: this.#sessionId,
|
|
1177
|
+
epoch: this.#epoch,
|
|
1178
|
+
attempt: this.#reconnectAttempts
|
|
1179
|
+
});
|
|
1180
|
+
if (this.#connector === void 0) {
|
|
1181
|
+
if (this.#reconnectAttempts >= this.#policy.maxReconnectAttempts) this.#attemptRecovery();
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
const controller = new AbortController();
|
|
1185
|
+
this.#pendingConnect = { abort: () => controller.abort() };
|
|
1186
|
+
void this.#connector.connect(controller.signal).then(
|
|
1187
|
+
(result) => this.#connected(result),
|
|
1188
|
+
(cause) => this.#connectFailed(cause)
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
#connected(result) {
|
|
1192
|
+
this.#pendingConnect = void 0;
|
|
1193
|
+
if (this.#disposed || this.#state.kind !== "recovering") {
|
|
1194
|
+
if (result.ok) result.value.close();
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (!result.ok) {
|
|
1198
|
+
this.#connectFailed(result.error);
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
this.#endpoint?.close();
|
|
1202
|
+
this.#endpoint = result.value;
|
|
1203
|
+
this.#lastError = void 0;
|
|
1204
|
+
this.#epoch += 1;
|
|
1205
|
+
this.#sequence = 0;
|
|
1206
|
+
this.#acknowledgedSequence = 0;
|
|
1207
|
+
this.#ledger.clear();
|
|
1208
|
+
this.#setState({ kind: "resyncing", sessionId: this.#sessionId, epoch: this.#epoch });
|
|
1209
|
+
}
|
|
1210
|
+
#connectFailed(cause) {
|
|
1211
|
+
this.#pendingConnect = void 0;
|
|
1212
|
+
if (this.#disposed || this.#state.kind !== "recovering") return;
|
|
1213
|
+
const failure = cause instanceof NetError ? cause : isEndpointError(cause) ? cause : recoveryFailure("connector attempt failed");
|
|
1214
|
+
if (this.#reconnectAttempts >= this.#policy.maxReconnectAttempts) {
|
|
1215
|
+
this.#setFailure(
|
|
1216
|
+
new NetError({
|
|
1217
|
+
code: "recovery-exhausted",
|
|
1218
|
+
expected: "reconnect attempts within the configured finite bound",
|
|
1219
|
+
hint: "inspect the endpoint failure and create a new session after exhaustion",
|
|
1220
|
+
detail: {
|
|
1221
|
+
attempts: this.#reconnectAttempts,
|
|
1222
|
+
maxAttempts: this.#policy.maxReconnectAttempts
|
|
1223
|
+
}
|
|
1224
|
+
})
|
|
1225
|
+
);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
this.#lastError = failure;
|
|
1229
|
+
this.advanceRecovery();
|
|
1230
|
+
}
|
|
1231
|
+
#handleAck(packet) {
|
|
1232
|
+
if (packet.sessionId !== this.#sessionId && !this.#sessionPeers.has(packet.sessionId))
|
|
1233
|
+
return err(
|
|
1234
|
+
new NetError({
|
|
1235
|
+
code: "recovery-rejected",
|
|
1236
|
+
expected: "an ACK for the current SessionId",
|
|
1237
|
+
hint: "discard ACKs from another logical session",
|
|
1238
|
+
detail: { reason: "ACK SessionId does not match the current session" }
|
|
1239
|
+
})
|
|
1240
|
+
);
|
|
1241
|
+
if (packet.epoch !== this.#epoch || packet.acknowledgedSequence > this.#sequence)
|
|
1242
|
+
return ok(void 0);
|
|
1243
|
+
if (packet.acknowledgedSequence <= this.#acknowledgedSequence) return ok(void 0);
|
|
1244
|
+
this.#acknowledgedSequence = packet.acknowledgedSequence;
|
|
1245
|
+
for (const sequence of this.#ledger.keys())
|
|
1246
|
+
if (sequence <= packet.acknowledgedSequence) this.#ledger.delete(sequence);
|
|
1247
|
+
return ok(void 0);
|
|
1248
|
+
}
|
|
1249
|
+
#receiveMessage(peerId, data, errors) {
|
|
1250
|
+
if (this.#state.kind === "recovering" || this.#state.kind === "failed" || this.#state.kind === "retired")
|
|
1251
|
+
return;
|
|
1252
|
+
const limits = this.#replica?.limits ?? DEFAULT_REPLICATION_LIMITS;
|
|
1253
|
+
const decoded = decodeReplicationPacket(data, limits);
|
|
1254
|
+
if (!decoded.ok) {
|
|
1255
|
+
if (this.#replica === void 0) {
|
|
1256
|
+
this.#queueRawMessage(peerId, data);
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
errors.push(decoded.error);
|
|
1260
|
+
this.#setFailure(decoded.error);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
if (decoded.value.kind === "session-open" || decoded.value.kind === "session-resume") {
|
|
1264
|
+
this.#bindSession(decoded.value.sessionId, peerId);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
if (decoded.value.kind === "ack") {
|
|
1268
|
+
const handled = this.#handleAck(decoded.value);
|
|
1269
|
+
if (!handled.ok) {
|
|
1270
|
+
errors.push(handled.error);
|
|
1271
|
+
this.#setFailure(handled.error);
|
|
1272
|
+
}
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
if (decoded.value.kind !== "baseline" && decoded.value.kind !== "delta") {
|
|
1276
|
+
if (decoded.value.kind === "rejection") {
|
|
1277
|
+
const failure = recoveryFailure(
|
|
1278
|
+
`peer rejected ${decoded.value.rejectedKind}: ${decoded.value.reason}`
|
|
1279
|
+
);
|
|
1280
|
+
errors.push(failure);
|
|
1281
|
+
this.#setFailure(failure);
|
|
1282
|
+
}
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (this.#replica === void 0) {
|
|
1286
|
+
this.#queueRawMessage(peerId, data);
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
const applied = decodeAndApplyReplicationPacket(
|
|
1290
|
+
this.#replica.coordinator,
|
|
1291
|
+
data,
|
|
1292
|
+
this.#replica.limits
|
|
1293
|
+
);
|
|
1294
|
+
if (!applied.ok) {
|
|
1295
|
+
errors.push(applied.error);
|
|
1296
|
+
this.#setFailure(applied.error);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const packetOutcome = this.#replica.coordinator.lastPacketOutcome;
|
|
1300
|
+
if (packetOutcome === "accepted") {
|
|
1301
|
+
this.#epoch = decoded.value.epoch;
|
|
1302
|
+
this.#sequence = decoded.value.sequence;
|
|
1303
|
+
this.#acknowledgedSequence = decoded.value.sequence;
|
|
1304
|
+
this.#setState({
|
|
1305
|
+
kind: "active",
|
|
1306
|
+
sessionId: this.#sessionId,
|
|
1307
|
+
epoch: this.#epoch,
|
|
1308
|
+
sequence: this.#sequence
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
if (packetOutcome === "accepted" || packetOutcome === "duplicate")
|
|
1312
|
+
this.#sendReplicationAck(peerId, decoded.value);
|
|
1313
|
+
}
|
|
1314
|
+
receiveEvents() {
|
|
1315
|
+
const errors = [];
|
|
1316
|
+
if (this.#disposed || this.#state.kind === "failed" || this.#state.kind === "retired")
|
|
1317
|
+
return errors;
|
|
1318
|
+
for (const event of this.#endpoint?.poll() ?? []) {
|
|
1319
|
+
if (event.kind === "peer-connected") {
|
|
1320
|
+
this.#peerIds.add(event.peerId);
|
|
1321
|
+
if (this.#replica !== void 0) this.#bindSession(this.#sessionId, event.peerId);
|
|
1322
|
+
else this.#bindSession(this.#sessionForPeer(event.peerId), event.peerId);
|
|
1323
|
+
this.#pendingFullPeers.add(event.peerId);
|
|
1324
|
+
} else if (event.kind === "peer-disconnected") {
|
|
1325
|
+
this.#forgetPeer(event.peerId);
|
|
1326
|
+
if (this.#replica !== void 0) {
|
|
1327
|
+
this.#replica.coordinator.clear();
|
|
1328
|
+
this.#beginRecovery();
|
|
1329
|
+
this.advanceRecovery();
|
|
1330
|
+
}
|
|
1331
|
+
} else this.#receiveMessage(event.peerId, event.data, errors);
|
|
1332
|
+
}
|
|
1333
|
+
return errors;
|
|
1334
|
+
}
|
|
1335
|
+
drainRawMessages() {
|
|
1336
|
+
return this.#rawMessages.splice(0);
|
|
1337
|
+
}
|
|
1338
|
+
getPeerSnapshot() {
|
|
1339
|
+
const peerIds = [...this.#peerIds].sort((left, right) => left - right);
|
|
1340
|
+
return { peerIds, connected: peerIds.length > 0 };
|
|
1341
|
+
}
|
|
1342
|
+
getSessionSnapshot() {
|
|
1343
|
+
const sessionIds = [...this.#sessionPeers.keys()].sort((left, right) => left - right);
|
|
1344
|
+
return { sessionIds, connected: sessionIds.length > 0 };
|
|
1345
|
+
}
|
|
1346
|
+
/** Return lifecycle, epoch, sequence, ledger, and owned-resource evidence. */
|
|
1347
|
+
getRecoverySnapshot() {
|
|
1348
|
+
return {
|
|
1349
|
+
sessionId: this.#sessionId,
|
|
1350
|
+
state: this.#state,
|
|
1351
|
+
pendingPackets: this.#ledger.size,
|
|
1352
|
+
maxPendingPackets: this.#policy.maxPendingPackets,
|
|
1353
|
+
acknowledgedSequence: this.#acknowledgedSequence,
|
|
1354
|
+
reconnectAttempts: this.#reconnectAttempts,
|
|
1355
|
+
epoch: this.#epoch,
|
|
1356
|
+
sequence: this.#sequence,
|
|
1357
|
+
...this.#lastError === void 0 ? {} : { lastError: this.#lastError },
|
|
1358
|
+
ownedResources: {
|
|
1359
|
+
pendingConnects: this.#pendingConnect === void 0 ? 0 : 1,
|
|
1360
|
+
timers: this.#retryTimer === void 0 ? 0 : 1,
|
|
1361
|
+
ledgers: this.#ledger.size === 0 ? 0 : 1,
|
|
1362
|
+
callbacks: 0
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
getResourceSnapshot() {
|
|
1367
|
+
return this.getRecoverySnapshot().ownedResources;
|
|
1368
|
+
}
|
|
1369
|
+
recover() {
|
|
1370
|
+
if (this.#state.kind === "retired" || this.#state.kind === "failed")
|
|
1371
|
+
return { kind: "retired", sessionId: this.#sessionId };
|
|
1372
|
+
if (this.#state.kind === "recovering")
|
|
1373
|
+
return { kind: "already-recovering", sessionId: this.#sessionId };
|
|
1374
|
+
this.#beginRecovery();
|
|
1375
|
+
return { kind: "started", sessionId: this.#sessionId };
|
|
1376
|
+
}
|
|
1377
|
+
advanceRecovery() {
|
|
1378
|
+
if (this.#state.kind !== "recovering") return;
|
|
1379
|
+
const delay = this.#policy.reconnectDelaysMs[Math.min(this.#reconnectAttempts, this.#policy.reconnectDelaysMs.length - 1)];
|
|
1380
|
+
if (delay === void 0 || delay === 0) this.#attemptRecovery();
|
|
1381
|
+
else {
|
|
1382
|
+
this.#retryTimer?.cancel();
|
|
1383
|
+
this.#retryTimer = this.#clock.schedule(delay, () => {
|
|
1384
|
+
this.#retryTimer = void 0;
|
|
1385
|
+
this.#attemptRecovery();
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
sendRaw(peerId, data) {
|
|
1390
|
+
if (this.#state.kind !== "active") return err(recoveryFailure("session is not active"));
|
|
1391
|
+
return this.#sendToPeer(peerId, data);
|
|
1392
|
+
}
|
|
1393
|
+
/** Send one application command through the current replica attachment. */
|
|
1394
|
+
sendToAuthority(sessionId, data) {
|
|
1395
|
+
if (sessionId !== this.#sessionId)
|
|
1396
|
+
return err(recoveryFailure("session id does not belong to this NetSession"));
|
|
1397
|
+
if (this.#state.kind === "recovering" || this.#state.kind === "failed" || this.#state.kind === "retired")
|
|
1398
|
+
return err(recoveryFailure("session is not connected to the authority"));
|
|
1399
|
+
const peerId = this.#peerForSession(sessionId);
|
|
1400
|
+
if (peerId === void 0) return err(recoveryFailure("authority peer is not connected"));
|
|
1401
|
+
if (this.#replica !== void 0) {
|
|
1402
|
+
const announced = this.#announceSession(peerId);
|
|
1403
|
+
if (!announced.ok) return announced;
|
|
1404
|
+
}
|
|
1405
|
+
return this.#sendToPeer(peerId, data);
|
|
1406
|
+
}
|
|
1407
|
+
/** Send one application message to an authority-owned logical session. */
|
|
1408
|
+
sendToSession(sessionId, data) {
|
|
1409
|
+
if (this.#state.kind === "failed" || this.#state.kind === "retired")
|
|
1410
|
+
return err(recoveryFailure("session is not connected to the authority"));
|
|
1411
|
+
const peerId = this.#peerForSession(sessionId);
|
|
1412
|
+
if (peerId === void 0) return err(recoveryFailure("logical session is not connected"));
|
|
1413
|
+
return this.#sendToPeer(peerId, data);
|
|
1414
|
+
}
|
|
1415
|
+
attachAuthority(authority) {
|
|
1416
|
+
this.#authority = authority;
|
|
1417
|
+
}
|
|
1418
|
+
requestFullBaseline(peerId) {
|
|
1419
|
+
if (this.#peerIds.has(peerId)) this.#pendingFullPeers.add(peerId);
|
|
1420
|
+
}
|
|
1421
|
+
requestFullBaselineForSession(sessionId) {
|
|
1422
|
+
const peerId = this.#sessionPeers.get(sessionId);
|
|
1423
|
+
if (peerId !== void 0) this.requestFullBaseline(peerId);
|
|
1424
|
+
}
|
|
1425
|
+
attachReplica(coordinator, limits) {
|
|
1426
|
+
this.#replica = { coordinator, limits };
|
|
1427
|
+
}
|
|
1428
|
+
#ledgerBoundError() {
|
|
1429
|
+
return new NetError({
|
|
1430
|
+
code: "recovery-rejected",
|
|
1431
|
+
expected: "published packets within the configured finite ACK bound",
|
|
1432
|
+
hint: "wait for a cumulative ACK before publishing more packets",
|
|
1433
|
+
detail: { reason: "ACK ledger bound reached" }
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
#ensurePublicationCapacity(expectedEpoch) {
|
|
1437
|
+
if (expectedEpoch === this.#epoch && this.#ledger.size >= this.#policy.maxPendingPackets)
|
|
1438
|
+
return err(this.#ledgerBoundError());
|
|
1439
|
+
return ok(void 0);
|
|
1440
|
+
}
|
|
1441
|
+
#reservePublished(packet) {
|
|
1442
|
+
if (packet.epoch !== this.#epoch) {
|
|
1443
|
+
this.#ledger.clear();
|
|
1444
|
+
this.#acknowledgedSequence = 0;
|
|
1445
|
+
this.#epoch = packet.epoch;
|
|
1446
|
+
}
|
|
1447
|
+
if (this.#ledger.size >= this.#policy.maxPendingPackets && !this.#ledger.has(packet.sequence))
|
|
1448
|
+
return err(this.#ledgerBoundError());
|
|
1449
|
+
this.#sequence = packet.sequence;
|
|
1450
|
+
this.#ledger.set(packet.sequence, packet.bytes);
|
|
1451
|
+
return ok(void 0);
|
|
1452
|
+
}
|
|
1453
|
+
#sendPublished(packet, peerIds) {
|
|
1454
|
+
const reserved = this.#reservePublished(packet);
|
|
1455
|
+
if (!reserved.ok) return reserved;
|
|
1456
|
+
if (this.#endpoint === void 0) return err(recoveryFailure("session has no endpoint"));
|
|
1457
|
+
let delivered = false;
|
|
1458
|
+
for (const peerId of peerIds) {
|
|
1459
|
+
const sent = this.#endpoint.send(peerId, packet.bytes);
|
|
1460
|
+
if (!sent.ok) {
|
|
1461
|
+
if (sent.error.code === "connection-closed") {
|
|
1462
|
+
this.#forgetPeer(peerId);
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
this.#ledger.delete(packet.sequence);
|
|
1466
|
+
return err(sent.error);
|
|
1467
|
+
}
|
|
1468
|
+
delivered = true;
|
|
1469
|
+
}
|
|
1470
|
+
if (!delivered) this.#ledger.delete(packet.sequence);
|
|
1471
|
+
return ok(void 0);
|
|
1472
|
+
}
|
|
1473
|
+
publish() {
|
|
1474
|
+
if (this.#authority === void 0 || this.#endpoint === void 0 || this.#peerIds.size === 0)
|
|
1475
|
+
return ok(void 0);
|
|
1476
|
+
if (this.#pendingFullPeers.size > 0) {
|
|
1477
|
+
const capacity2 = this.#ensurePublicationCapacity(this.#authority.nextPublicationEpoch(true));
|
|
1478
|
+
if (!capacity2.ok) return capacity2;
|
|
1479
|
+
const published2 = this.#authority.publishFull();
|
|
1480
|
+
if (!published2.ok) return err(published2.error);
|
|
1481
|
+
const sent2 = this.#sendPublished(published2.value, [...this.#peerIds]);
|
|
1482
|
+
if (!sent2.ok) return err(sent2.error);
|
|
1483
|
+
this.#pendingFullPeers.clear();
|
|
1484
|
+
return ok(void 0);
|
|
1485
|
+
}
|
|
1486
|
+
const capacity = this.#ensurePublicationCapacity(this.#authority.nextPublicationEpoch());
|
|
1487
|
+
if (!capacity.ok) return capacity;
|
|
1488
|
+
const published = this.#authority.publish();
|
|
1489
|
+
if (!published.ok) return err(published.error);
|
|
1490
|
+
const sent = this.#sendPublished(published.value, [...this.#peerIds]);
|
|
1491
|
+
if (!sent.ok) return err(sent.error);
|
|
1492
|
+
return ok(void 0);
|
|
1493
|
+
}
|
|
1494
|
+
dispose() {
|
|
1495
|
+
if (this.#disposed) return;
|
|
1496
|
+
this.#disposed = true;
|
|
1497
|
+
this.#clearRecoveryWork();
|
|
1498
|
+
this.#endpoint?.close();
|
|
1499
|
+
this.#endpoint = void 0;
|
|
1500
|
+
this.#replica?.coordinator.clear();
|
|
1501
|
+
this.#replica = void 0;
|
|
1502
|
+
this.#authority = void 0;
|
|
1503
|
+
this.#peerIds.clear();
|
|
1504
|
+
this.#sessionPeers.clear();
|
|
1505
|
+
this.#announcedPeers.clear();
|
|
1506
|
+
this.#sessionAnnounced = false;
|
|
1507
|
+
this.#pendingFullPeers.clear();
|
|
1508
|
+
this.#rawMessages = [];
|
|
1509
|
+
if (this.#state.kind !== "retired")
|
|
1510
|
+
this.#setState({ kind: "retired", sessionId: this.#sessionId, reason: "disposed" });
|
|
1511
|
+
}
|
|
1512
|
+
#queueRawMessage(peerId, data) {
|
|
1513
|
+
if (this.#rawMessages.length >= this.#maxRawMessages) return;
|
|
1514
|
+
this.#rawMessages.push({
|
|
1515
|
+
peerId,
|
|
1516
|
+
sessionId: this.#sessionForPeer(peerId),
|
|
1517
|
+
data: new Uint8Array(data)
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
#sessionForPeer(peerId) {
|
|
1521
|
+
for (const [sessionId2, mappedPeerId] of this.#sessionPeers)
|
|
1522
|
+
if (mappedPeerId === peerId) return sessionId2;
|
|
1523
|
+
if (this.#replica !== void 0) {
|
|
1524
|
+
this.#bindSession(this.#sessionId, peerId);
|
|
1525
|
+
return this.#sessionId;
|
|
1526
|
+
}
|
|
1527
|
+
const created = createSessionId(peerId);
|
|
1528
|
+
const sessionId = created.ok ? created.value : this.#sessionId;
|
|
1529
|
+
this.#bindSession(sessionId, peerId);
|
|
1530
|
+
return sessionId;
|
|
1531
|
+
}
|
|
1532
|
+
#bindSession(sessionId, peerId) {
|
|
1533
|
+
for (const [mappedSessionId, mappedPeerId] of this.#sessionPeers)
|
|
1534
|
+
if (mappedSessionId === sessionId || mappedPeerId === peerId)
|
|
1535
|
+
this.#sessionPeers.delete(mappedSessionId);
|
|
1536
|
+
this.#sessionPeers.set(sessionId, peerId);
|
|
1537
|
+
}
|
|
1538
|
+
#forgetPeer(peerId) {
|
|
1539
|
+
this.#peerIds.delete(peerId);
|
|
1540
|
+
for (const [sessionId, mappedPeerId] of this.#sessionPeers)
|
|
1541
|
+
if (mappedPeerId === peerId) this.#sessionPeers.delete(sessionId);
|
|
1542
|
+
this.#announcedPeers.delete(peerId);
|
|
1543
|
+
this.#pendingFullPeers.delete(peerId);
|
|
1544
|
+
}
|
|
1545
|
+
#peerForSession(sessionId) {
|
|
1546
|
+
const mapped = this.#sessionPeers.get(sessionId);
|
|
1547
|
+
if (mapped !== void 0 && this.#peerIds.has(mapped)) return mapped;
|
|
1548
|
+
if (this.#replica !== void 0 && this.#peerIds.size === 1) {
|
|
1549
|
+
const peerId = [...this.#peerIds][0];
|
|
1550
|
+
if (peerId !== void 0) {
|
|
1551
|
+
this.#bindSession(sessionId, peerId);
|
|
1552
|
+
return peerId;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
return void 0;
|
|
1556
|
+
}
|
|
1557
|
+
#announceSession(peerId) {
|
|
1558
|
+
if (this.#announcedPeers.has(peerId)) return ok(void 0);
|
|
1559
|
+
const packet = {
|
|
1560
|
+
version: 2,
|
|
1561
|
+
kind: this.#sessionAnnounced ? "session-resume" : "session-open",
|
|
1562
|
+
sessionId: this.#sessionId,
|
|
1563
|
+
epoch: this.#epoch,
|
|
1564
|
+
sequence: 0
|
|
1565
|
+
};
|
|
1566
|
+
const encoded = encodeReplicationPacket(packet, DEFAULT_REPLICATION_LIMITS);
|
|
1567
|
+
if (!encoded.ok) return err(encoded.error);
|
|
1568
|
+
const sent = this.#sendToPeer(peerId, encoded.value);
|
|
1569
|
+
if (!sent.ok) return sent;
|
|
1570
|
+
this.#announcedPeers.add(peerId);
|
|
1571
|
+
this.#sessionAnnounced = true;
|
|
1572
|
+
return ok(void 0);
|
|
1573
|
+
}
|
|
1574
|
+
#sendToPeer(peerId, data) {
|
|
1575
|
+
const result = this.#endpoint?.send(peerId, data);
|
|
1576
|
+
if (result === void 0) return err(recoveryFailure("session has no endpoint"));
|
|
1577
|
+
return result.ok ? ok(void 0) : err(result.error);
|
|
1578
|
+
}
|
|
1579
|
+
/** ACK accepted data at the session boundary; consumers should not reimplement this wire step. */
|
|
1580
|
+
#sendReplicationAck(peerId, packet) {
|
|
1581
|
+
const encoded = encodeReplicationPacket(
|
|
1582
|
+
{
|
|
1583
|
+
version: 2,
|
|
1584
|
+
kind: "ack",
|
|
1585
|
+
sessionId: packet.sessionId,
|
|
1586
|
+
epoch: packet.epoch,
|
|
1587
|
+
acknowledgedSequence: packet.sequence
|
|
1588
|
+
},
|
|
1589
|
+
DEFAULT_REPLICATION_LIMITS
|
|
1590
|
+
);
|
|
1591
|
+
if (!encoded.ok) {
|
|
1592
|
+
this.#setFailure(encoded.error);
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
const sent = this.#sendToPeer(peerId, encoded.value);
|
|
1596
|
+
if (!sent.ok) this.#lastError = sent.error;
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
function netPlugin(config) {
|
|
1600
|
+
return {
|
|
1601
|
+
name: "net-session",
|
|
1602
|
+
inject: ["world"],
|
|
1603
|
+
apply(ctx) {
|
|
1604
|
+
const world = ctx.world;
|
|
1605
|
+
const session = new NetSession({
|
|
1606
|
+
...config.endpoint === void 0 ? {} : { endpoint: config.endpoint },
|
|
1607
|
+
...config.connector === void 0 ? {} : { connector: config.connector },
|
|
1608
|
+
...config.sessionId === void 0 ? {} : { sessionId: config.sessionId },
|
|
1609
|
+
...config.recovery === void 0 ? {} : { recovery: config.recovery },
|
|
1610
|
+
...config.clock === void 0 ? {} : { clock: config.clock },
|
|
1611
|
+
maxRawMessages: config.maxRawMessages ?? 256
|
|
1612
|
+
});
|
|
1613
|
+
ctx.effect(() => {
|
|
1614
|
+
world.insertResource("net-session", session);
|
|
1615
|
+
if (config.connector !== void 0 && config.endpoint === void 0) {
|
|
1616
|
+
session.recover();
|
|
1617
|
+
session.advanceRecovery();
|
|
1618
|
+
}
|
|
1619
|
+
return () => {
|
|
1620
|
+
session.dispose();
|
|
1621
|
+
world.removeResource("net-session");
|
|
1622
|
+
};
|
|
1623
|
+
}, "net/session-resource");
|
|
1624
|
+
ctx.effect(() => {
|
|
1625
|
+
world.addSystem(Update, {
|
|
1626
|
+
name: "net-receive",
|
|
1627
|
+
queries: [],
|
|
1628
|
+
before: [FixedUpdate],
|
|
1629
|
+
fn: (world2) => world2.getResource("net-session").receiveEvents()
|
|
1630
|
+
}).unwrap();
|
|
1631
|
+
return () => world.removeSystem(Update, "net-receive");
|
|
1632
|
+
}, "net/receive");
|
|
1633
|
+
ctx.effect(() => {
|
|
1634
|
+
world.addSystem(Update, {
|
|
1635
|
+
name: "net-publish",
|
|
1636
|
+
queries: [],
|
|
1637
|
+
after: [FixedUpdate],
|
|
1638
|
+
fn: (world2) => world2.getResource("net-session").publish()
|
|
1639
|
+
}).unwrap();
|
|
1640
|
+
return () => world.removeSystem(Update, "net-publish");
|
|
1641
|
+
}, "net/publish");
|
|
1642
|
+
}
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
export { AuthorityCoordinator, DEFAULT_NET_RECOVERY_POLICY, DEFAULT_REPLICATION_LIMITS, ENDPOINT_ERROR_HINTS, ENDPOINT_EXPECTED, EndpointError, NetError, NetSession, RECOVERY_ERROR_CODES, REPLICATION_PROTOCOL_PREFIX, REPLICATION_PROTOCOL_VERSION, ReplicaCoordinator, applyReplicationPacket, createAuthorityCoordinator, createMemoryEndpointConnector, createMemoryEndpointPair, createMemoryEndpointPairWithController, createReplicaCoordinator, createSessionId, decodeAndApplyReplicationPacket, decodeReplicationPacket, defineReplication, encodeReplicationPacket, isEndpointError, isLegalNetSessionTransition, netPlugin, resolveNetRecoveryPolicy, transitionNetSessionState, validateHandshake, validateNetRecoveryPolicy };
|
|
1647
|
+
//# sourceMappingURL=index.mjs.map
|
|
1648
|
+
//# sourceMappingURL=index.mjs.map
|