@forgeax/engine-net 0.1.4 → 0.1.7
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 +156 -105
- package/dist/.tsbuildinfo +1 -1
- package/dist/endpoint/endpoint.d.ts +7 -0
- package/dist/endpoint/endpoint.d.ts.map +1 -1
- package/dist/endpoint/memory.d.ts +3 -1
- package/dist/endpoint/memory.d.ts.map +1 -1
- package/dist/index.d.ts +11 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +862 -117
- package/dist/index.mjs.map +1 -1
- package/dist/replication/authority.d.ts +8 -6
- package/dist/replication/authority.d.ts.map +1 -1
- package/dist/replication/codec.d.ts +4 -22
- package/dist/replication/codec.d.ts.map +1 -1
- package/dist/replication/constants.d.ts +4 -1
- package/dist/replication/constants.d.ts.map +1 -1
- package/dist/replication/errors.d.ts +24 -4
- package/dist/replication/errors.d.ts.map +1 -1
- package/dist/replication/protocol.d.ts +63 -0
- package/dist/replication/protocol.d.ts.map +1 -0
- package/dist/replication/replica.d.ts +9 -6
- package/dist/replication/replica.d.ts.map +1 -1
- package/dist/session/net-session.d.ts +40 -6
- package/dist/session/net-session.d.ts.map +1 -1
- package/dist/session/recovery.d.ts +93 -0
- package/dist/session/recovery.d.ts.map +1 -0
- package/dist/session/session-plugin.d.ts +8 -2
- package/dist/session/session-plugin.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/endpoint/endpoint.ts +8 -0
- package/src/endpoint/memory.ts +23 -1
- package/src/index.ts +56 -12
- package/src/replication/authority.ts +55 -23
- package/src/replication/codec.ts +167 -101
- package/src/replication/constants.ts +5 -1
- package/src/replication/errors.ts +22 -4
- package/src/replication/protocol.ts +86 -0
- package/src/replication/replica.ts +89 -28
- package/src/session/net-session.ts +617 -38
- package/src/session/recovery.ts +204 -0
- package/src/session/session-plugin.ts +21 -5
package/dist/index.mjs
CHANGED
|
@@ -64,8 +64,8 @@ var ENDPOINT_EXPECTED = Object.fromEntries(
|
|
|
64
64
|
var ENDPOINT_ERROR_HINTS = Object.fromEntries(
|
|
65
65
|
Object.entries(endpointErrorPolicy).map(([code, policy]) => [code, policy.hint])
|
|
66
66
|
);
|
|
67
|
-
function isEndpointError(
|
|
68
|
-
return
|
|
67
|
+
function isEndpointError(err9) {
|
|
68
|
+
return err9 instanceof EndpointErrorClass;
|
|
69
69
|
}
|
|
70
70
|
var MemoryEndpoint = class {
|
|
71
71
|
_peerId;
|
|
@@ -201,9 +201,28 @@ function createMemoryEndpointPairWithController() {
|
|
|
201
201
|
};
|
|
202
202
|
return { endpoints: [epA, epB], controller };
|
|
203
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
|
+
}
|
|
204
222
|
|
|
205
223
|
// src/replication/constants.ts
|
|
206
|
-
var REPLICATION_PROTOCOL_VERSION =
|
|
224
|
+
var REPLICATION_PROTOCOL_VERSION = 2;
|
|
225
|
+
var REPLICATION_PROTOCOL_PREFIX = "FXRP2";
|
|
207
226
|
|
|
208
227
|
// src/replication/errors.ts
|
|
209
228
|
var NetErrorClass = class extends Error {
|
|
@@ -223,13 +242,6 @@ var NetErrorClass = class extends Error {
|
|
|
223
242
|
var NetError = NetErrorClass;
|
|
224
243
|
|
|
225
244
|
// src/replication/codec.ts
|
|
226
|
-
var REPLICATION_ENTITY_KINDS = [
|
|
227
|
-
"upsert",
|
|
228
|
-
"despawn"
|
|
229
|
-
];
|
|
230
|
-
function isReplicationEntityKind(value) {
|
|
231
|
-
return REPLICATION_ENTITY_KINDS.some((kind) => kind === value);
|
|
232
|
-
}
|
|
233
245
|
var TYPED_ARRAYS = {
|
|
234
246
|
Float32Array,
|
|
235
247
|
Float64Array,
|
|
@@ -241,6 +253,30 @@ var TYPED_ARRAYS = {
|
|
|
241
253
|
Uint16Array,
|
|
242
254
|
Uint32Array
|
|
243
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
|
+
}
|
|
244
280
|
function typedArrayName(value) {
|
|
245
281
|
for (const [name, typedArrayConstructor] of Object.entries(TYPED_ARRAYS)) {
|
|
246
282
|
if (value instanceof typedArrayConstructor) return name;
|
|
@@ -252,11 +288,10 @@ function canonicalize(value) {
|
|
|
252
288
|
if (name !== void 0)
|
|
253
289
|
return { $typedArray: name, values: Array.from(value) };
|
|
254
290
|
if (Array.isArray(value)) return value.map(canonicalize);
|
|
255
|
-
if (value !== null && typeof value === "object")
|
|
291
|
+
if (value !== null && typeof value === "object")
|
|
256
292
|
return Object.fromEntries(
|
|
257
293
|
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])
|
|
258
294
|
);
|
|
259
|
-
}
|
|
260
295
|
return value;
|
|
261
296
|
}
|
|
262
297
|
function reviveTypedArrays(value) {
|
|
@@ -271,7 +306,7 @@ function reviveTypedArrays(value) {
|
|
|
271
306
|
}
|
|
272
307
|
if (value === null || typeof value !== "object") return { value };
|
|
273
308
|
const record = value;
|
|
274
|
-
if ("$typedArray" in record
|
|
309
|
+
if ("$typedArray" in record) {
|
|
275
310
|
if (Object.keys(record).length !== 2 || typeof record.$typedArray !== "string" || !Array.isArray(record.values))
|
|
276
311
|
return { reason: "typed-array tag must contain only an allowlisted name and values array" };
|
|
277
312
|
const typedArrayConstructor = TYPED_ARRAYS[record.$typedArray];
|
|
@@ -295,11 +330,57 @@ function limitError(limit, actual, maximum) {
|
|
|
295
330
|
detail: { limit, actual, maximum }
|
|
296
331
|
});
|
|
297
332
|
}
|
|
298
|
-
function
|
|
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) {
|
|
299
380
|
if (bytes !== void 0 && bytes.byteLength > limits.maxMessageBytes)
|
|
300
381
|
return limitError("maxMessageBytes", bytes.byteLength, limits.maxMessageBytes);
|
|
301
|
-
if (
|
|
302
|
-
return limitError("maxEntities",
|
|
382
|
+
if (packet.entities.length > limits.maxEntities)
|
|
383
|
+
return limitError("maxEntities", packet.entities.length, limits.maxEntities);
|
|
303
384
|
let operations = 0;
|
|
304
385
|
const visit = (value) => {
|
|
305
386
|
if (typeof value === "string" && new TextEncoder().encode(value).byteLength > limits.maxStringBytes)
|
|
@@ -332,7 +413,7 @@ function validateLimits(batch, bytes, limits) {
|
|
|
332
413
|
}
|
|
333
414
|
return null;
|
|
334
415
|
};
|
|
335
|
-
for (const entity of
|
|
416
|
+
for (const entity of packet.entities) {
|
|
336
417
|
operations += entity.components.length;
|
|
337
418
|
for (const component of entity.components) {
|
|
338
419
|
const problem = visit(component.data);
|
|
@@ -342,58 +423,57 @@ function validateLimits(batch, bytes, limits) {
|
|
|
342
423
|
return operations > limits.maxComponentOperations ? limitError("maxComponentOperations", operations, limits.maxComponentOperations) : null;
|
|
343
424
|
}
|
|
344
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") };
|
|
345
430
|
try {
|
|
346
|
-
const decoded = JSON.parse(
|
|
431
|
+
const decoded = JSON.parse(text.slice(separator + 1));
|
|
347
432
|
const revived = reviveTypedArrays(decoded);
|
|
348
|
-
if ("reason" in revived) return revived;
|
|
433
|
+
if ("reason" in revived) return { error: invalid(revived.reason) };
|
|
349
434
|
if (revived.value === null || typeof revived.value !== "object")
|
|
350
|
-
return {
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
};
|
|
368
|
-
}
|
|
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) };
|
|
369
452
|
}
|
|
370
|
-
return {
|
|
453
|
+
return { packet };
|
|
371
454
|
} catch {
|
|
372
|
-
return {
|
|
455
|
+
return { error: invalid("payload is not valid JSON") };
|
|
373
456
|
}
|
|
374
457
|
}
|
|
375
|
-
function
|
|
376
|
-
|
|
377
|
-
|
|
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;
|
|
378
468
|
return failure ? err(failure) : ok(bytes);
|
|
379
469
|
}
|
|
380
|
-
function
|
|
470
|
+
function decodeReplicationPacket(bytes, limits) {
|
|
381
471
|
if (bytes.byteLength > limits.maxMessageBytes)
|
|
382
472
|
return err(limitError("maxMessageBytes", bytes.byteLength, limits.maxMessageBytes));
|
|
383
473
|
const parsed = parse(bytes);
|
|
384
|
-
if ("
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
code: "decode-invalid-payload",
|
|
388
|
-
expected: `a version ${REPLICATION_PROTOCOL_VERSION} canonical replication batch`,
|
|
389
|
-
hint: "send bytes produced by the replication codec for the negotiated protocol",
|
|
390
|
-
detail: {
|
|
391
|
-
reason: "reason" in parsed ? parsed.reason : "batch protocol version does not match the decoder"
|
|
392
|
-
}
|
|
393
|
-
})
|
|
394
|
-
);
|
|
395
|
-
const failure = validateLimits(parsed.batch, bytes, limits);
|
|
396
|
-
return failure ? err(failure) : ok(parsed.batch);
|
|
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);
|
|
397
477
|
}
|
|
398
478
|
var DEFAULT_REPLICATION_LIMITS = {
|
|
399
479
|
maxMessageBytes: 64 * 1024,
|
|
@@ -474,9 +554,13 @@ var AuthorityCoordinator = class {
|
|
|
474
554
|
#known = /* @__PURE__ */ new Map();
|
|
475
555
|
#nextId = 1;
|
|
476
556
|
#tick = 0;
|
|
477
|
-
|
|
557
|
+
#epoch = 0;
|
|
558
|
+
#sequence = 0;
|
|
559
|
+
#sessionId;
|
|
560
|
+
constructor(world, profile, sessionId = 1) {
|
|
478
561
|
this.#world = world;
|
|
479
562
|
this.#profile = profile;
|
|
563
|
+
this.#sessionId = sessionId;
|
|
480
564
|
}
|
|
481
565
|
idFor(entity) {
|
|
482
566
|
return this.#ids.get(entity) ?? 0;
|
|
@@ -487,6 +571,9 @@ var AuthorityCoordinator = class {
|
|
|
487
571
|
publishFull() {
|
|
488
572
|
return this.#publish(true);
|
|
489
573
|
}
|
|
574
|
+
nextPublicationEpoch(forceFull = false) {
|
|
575
|
+
return forceFull && this.#tick > 0 ? this.#epoch + 1 : this.#epoch;
|
|
576
|
+
}
|
|
490
577
|
#publish(forceFull) {
|
|
491
578
|
const candidateIds = new Map(this.#ids);
|
|
492
579
|
let candidateNextId = this.#nextId;
|
|
@@ -515,6 +602,14 @@ var AuthorityCoordinator = class {
|
|
|
515
602
|
if (id !== void 0) current.set(entity, { id, components });
|
|
516
603
|
}
|
|
517
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;
|
|
518
613
|
const entities = [];
|
|
519
614
|
for (const [entity, entry] of current) {
|
|
520
615
|
const prior = this.#known.get(entity);
|
|
@@ -543,15 +638,27 @@ var AuthorityCoordinator = class {
|
|
|
543
638
|
for (const [entity] of candidateIds) {
|
|
544
639
|
if (!current.has(entity)) candidateIds.delete(entity);
|
|
545
640
|
}
|
|
546
|
-
const
|
|
641
|
+
const packet = full ? {
|
|
547
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,
|
|
548
656
|
fingerprint: this.#profile.fingerprint,
|
|
549
657
|
tick: this.#tick + 1,
|
|
550
|
-
full,
|
|
551
658
|
entities
|
|
552
659
|
};
|
|
553
|
-
const encoded =
|
|
554
|
-
|
|
660
|
+
const encoded = encodeReplicationPacket(
|
|
661
|
+
packet,
|
|
555
662
|
this.#profile.limits ?? DEFAULT_REPLICATION_LIMITS
|
|
556
663
|
);
|
|
557
664
|
if (!encoded.ok) return err(encoded.error);
|
|
@@ -560,8 +667,10 @@ var AuthorityCoordinator = class {
|
|
|
560
667
|
this.#known.clear();
|
|
561
668
|
for (const [entity, known] of candidateKnown) this.#known.set(entity, known);
|
|
562
669
|
this.#nextId = candidateNextId;
|
|
563
|
-
this.#tick =
|
|
564
|
-
|
|
670
|
+
this.#tick = packet.tick;
|
|
671
|
+
this.#epoch = nextEpoch;
|
|
672
|
+
this.#sequence = nextSequence;
|
|
673
|
+
return ok({ ...packet, bytes: encoded.value });
|
|
565
674
|
}
|
|
566
675
|
};
|
|
567
676
|
function createAuthorityCoordinator(world, profile) {
|
|
@@ -582,14 +691,15 @@ function validateHandshake(local, remote) {
|
|
|
582
691
|
var ReplicaCoordinator = class {
|
|
583
692
|
#world;
|
|
584
693
|
#profile;
|
|
585
|
-
#endpoint;
|
|
586
694
|
#entities = /* @__PURE__ */ new Map();
|
|
587
695
|
#lastTick = 0;
|
|
696
|
+
#epoch = -1;
|
|
697
|
+
#lastSequence = 0;
|
|
698
|
+
#lastPacketOutcome = "accepted";
|
|
588
699
|
#stopped = false;
|
|
589
|
-
constructor(world, profile,
|
|
700
|
+
constructor(world, profile, _endpoint) {
|
|
590
701
|
this.#world = world;
|
|
591
702
|
this.#profile = profile;
|
|
592
|
-
this.#endpoint = endpoint;
|
|
593
703
|
}
|
|
594
704
|
entityFor(id) {
|
|
595
705
|
return this.#entities.get(id);
|
|
@@ -607,7 +717,6 @@ var ReplicaCoordinator = class {
|
|
|
607
717
|
})).sort((a, b) => a.id - b.id);
|
|
608
718
|
}
|
|
609
719
|
disconnect() {
|
|
610
|
-
this.#endpoint?.close();
|
|
611
720
|
}
|
|
612
721
|
/** Remove the last replica baseline when the authority connection closes. */
|
|
613
722
|
clear() {
|
|
@@ -620,13 +729,21 @@ var ReplicaCoordinator = class {
|
|
|
620
729
|
get tick() {
|
|
621
730
|
return this.#lastTick;
|
|
622
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
|
+
}
|
|
623
739
|
#entityReferences(value) {
|
|
624
740
|
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
|
|
625
741
|
return Array.from(value);
|
|
626
742
|
}
|
|
627
743
|
return [];
|
|
628
744
|
}
|
|
629
|
-
validate(
|
|
745
|
+
validate(packet) {
|
|
746
|
+
this.#lastPacketOutcome = "accepted";
|
|
630
747
|
if (this.#stopped)
|
|
631
748
|
return new NetError({
|
|
632
749
|
code: "apply-invariant-failed",
|
|
@@ -634,22 +751,54 @@ var ReplicaCoordinator = class {
|
|
|
634
751
|
hint: "create a new session after a fatal apply failure",
|
|
635
752
|
detail: { reason: "replication stopped" }
|
|
636
753
|
});
|
|
637
|
-
if (
|
|
754
|
+
if (packet.fingerprint !== this.#profile.fingerprint)
|
|
638
755
|
return new NetError({
|
|
639
756
|
code: "schema-invalid",
|
|
640
757
|
expected: "a batch for the negotiated replication profile",
|
|
641
758
|
hint: "complete handshake before applying replication bytes",
|
|
642
759
|
detail: { component: "", reason: "fingerprint mismatch" }
|
|
643
760
|
});
|
|
644
|
-
|
|
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)
|
|
645
793
|
return new NetError({
|
|
646
794
|
code: "ordering-invalid-tick",
|
|
647
795
|
expected: "a strictly monotonic authority tick",
|
|
648
796
|
hint: "discard duplicate, stale, and out-of-order batches",
|
|
649
|
-
detail: { receivedTick:
|
|
797
|
+
detail: { receivedTick: packet.tick, lastTick: this.#lastTick }
|
|
650
798
|
});
|
|
651
799
|
const batchIds = /* @__PURE__ */ new Set();
|
|
652
|
-
|
|
800
|
+
const knownIds = newEpoch ? /* @__PURE__ */ new Set() : new Set(this.#entities.keys());
|
|
801
|
+
for (const record of packet.entities) {
|
|
653
802
|
if (!Number.isSafeInteger(record.id) || record.id <= 0 || batchIds.has(record.id))
|
|
654
803
|
return new NetError({
|
|
655
804
|
code: "identity-invalid",
|
|
@@ -659,8 +808,8 @@ var ReplicaCoordinator = class {
|
|
|
659
808
|
});
|
|
660
809
|
batchIds.add(record.id);
|
|
661
810
|
}
|
|
662
|
-
for (const record of
|
|
663
|
-
if (record.kind === "despawn" && !
|
|
811
|
+
for (const record of packet.entities) {
|
|
812
|
+
if (record.kind === "despawn" && !knownIds.has(record.id))
|
|
664
813
|
return new NetError({
|
|
665
814
|
code: "identity-invalid",
|
|
666
815
|
expected: "a known identity for despawn",
|
|
@@ -690,7 +839,7 @@ var ReplicaCoordinator = class {
|
|
|
690
839
|
const kind = classifyEntityField(component, field);
|
|
691
840
|
const refs = kind?.isArray ? this.#entityReferences(value) : kind ? [value] : [];
|
|
692
841
|
for (const reference of refs)
|
|
693
|
-
if (reference !== null && (typeof reference !== "number" || reference === 0 || !
|
|
842
|
+
if (reference !== null && (typeof reference !== "number" || reference === 0 || !knownIds.has(reference) && !batchIds.has(reference)))
|
|
694
843
|
return new NetError({
|
|
695
844
|
code: "remap-unresolved-reference",
|
|
696
845
|
expected: "every entity reference to resolve in the current or same batch",
|
|
@@ -702,17 +851,26 @@ var ReplicaCoordinator = class {
|
|
|
702
851
|
}
|
|
703
852
|
return null;
|
|
704
853
|
}
|
|
705
|
-
apply(
|
|
706
|
-
const failure = this.validate(
|
|
854
|
+
apply(packet) {
|
|
855
|
+
const failure = this.validate(packet);
|
|
707
856
|
if (failure) {
|
|
708
|
-
this.disconnect();
|
|
709
857
|
return err(failure);
|
|
710
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;
|
|
711
865
|
try {
|
|
712
|
-
|
|
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)
|
|
713
871
|
if (record.kind === "upsert" && !this.#entities.has(record.id))
|
|
714
872
|
this.#entities.set(record.id, this.#world.spawn().unwrap());
|
|
715
|
-
for (const record of
|
|
873
|
+
for (const record of packet.entities)
|
|
716
874
|
if (record.kind === "upsert") {
|
|
717
875
|
const entity = this.#entities.get(record.id);
|
|
718
876
|
if (entity === void 0) throw new Error(`missing allocated entity ${record.id}`);
|
|
@@ -747,14 +905,17 @@ var ReplicaCoordinator = class {
|
|
|
747
905
|
if (!write.ok) throw write.error;
|
|
748
906
|
}
|
|
749
907
|
}
|
|
750
|
-
for (const record of
|
|
908
|
+
for (const record of packet.entities)
|
|
751
909
|
if (record.kind === "despawn") {
|
|
752
910
|
const entity = this.#entities.get(record.id);
|
|
753
911
|
if (entity === void 0) throw new Error(`missing despawn entity ${record.id}`);
|
|
754
912
|
this.#world.despawn(entity).unwrap();
|
|
755
913
|
this.#entities.delete(record.id);
|
|
756
914
|
}
|
|
757
|
-
this.#
|
|
915
|
+
this.#epoch = packet.epoch;
|
|
916
|
+
this.#lastSequence = packet.sequence;
|
|
917
|
+
this.#lastTick = packet.tick;
|
|
918
|
+
this.#lastPacketOutcome = "accepted";
|
|
758
919
|
return ok(void 0);
|
|
759
920
|
} catch (cause) {
|
|
760
921
|
this.#stopped = true;
|
|
@@ -772,51 +933,402 @@ var ReplicaCoordinator = class {
|
|
|
772
933
|
function createReplicaCoordinator(world, profile, endpoint) {
|
|
773
934
|
return new ReplicaCoordinator(world, profile, endpoint);
|
|
774
935
|
}
|
|
775
|
-
function
|
|
776
|
-
return replica.apply(
|
|
936
|
+
function applyReplicationPacket(replica, packet) {
|
|
937
|
+
return replica.apply(packet);
|
|
777
938
|
}
|
|
778
|
-
function
|
|
779
|
-
const decoded =
|
|
939
|
+
function decodeAndApplyReplicationPacket(replica, bytes, limits) {
|
|
940
|
+
const decoded = decodeReplicationPacket(bytes, limits);
|
|
780
941
|
if (!decoded.ok) {
|
|
781
|
-
replica.disconnect();
|
|
782
942
|
return err(decoded.error);
|
|
783
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
|
+
}
|
|
784
954
|
return replica.apply(decoded.value);
|
|
785
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
|
+
}
|
|
786
1066
|
var NetSession = class {
|
|
787
1067
|
#endpoint;
|
|
1068
|
+
#connector;
|
|
1069
|
+
#clock;
|
|
1070
|
+
#policy;
|
|
1071
|
+
#sessionId;
|
|
788
1072
|
#peerIds = /* @__PURE__ */ new Set();
|
|
1073
|
+
#sessionPeers = /* @__PURE__ */ new Map();
|
|
1074
|
+
#announcedPeers = /* @__PURE__ */ new Set();
|
|
1075
|
+
#sessionAnnounced = false;
|
|
789
1076
|
#rawMessages = [];
|
|
790
1077
|
#maxRawMessages;
|
|
791
1078
|
#authority;
|
|
792
1079
|
#pendingFullPeers = /* @__PURE__ */ new Set();
|
|
793
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;
|
|
794
1091
|
constructor(config) {
|
|
795
1092
|
this.#endpoint = config.endpoint;
|
|
1093
|
+
this.#connector = config.connector;
|
|
1094
|
+
this.#clock = config.clock ?? defaultClock;
|
|
796
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);
|
|
797
1313
|
}
|
|
798
1314
|
receiveEvents() {
|
|
799
1315
|
const errors = [];
|
|
800
|
-
|
|
1316
|
+
if (this.#disposed || this.#state.kind === "failed" || this.#state.kind === "retired")
|
|
1317
|
+
return errors;
|
|
1318
|
+
for (const event of this.#endpoint?.poll() ?? []) {
|
|
801
1319
|
if (event.kind === "peer-connected") {
|
|
802
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);
|
|
803
1323
|
this.#pendingFullPeers.add(event.peerId);
|
|
804
1324
|
} else if (event.kind === "peer-disconnected") {
|
|
805
|
-
this.#
|
|
806
|
-
this.#pendingFullPeers.delete(event.peerId);
|
|
807
|
-
this.#replica?.coordinator.clear();
|
|
808
|
-
} else {
|
|
1325
|
+
this.#forgetPeer(event.peerId);
|
|
809
1326
|
if (this.#replica !== void 0) {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
this.#replica.limits
|
|
814
|
-
);
|
|
815
|
-
if (!result.ok) errors.push(result.error);
|
|
816
|
-
} else if (this.#rawMessages.length < this.#maxRawMessages) {
|
|
817
|
-
this.#rawMessages.push({ peerId: event.peerId, data: event.data });
|
|
1327
|
+
this.#replica.coordinator.clear();
|
|
1328
|
+
this.#beginRecovery();
|
|
1329
|
+
this.advanceRecovery();
|
|
818
1330
|
}
|
|
819
|
-
}
|
|
1331
|
+
} else this.#receiveMessage(event.peerId, event.data, errors);
|
|
820
1332
|
}
|
|
821
1333
|
return errors;
|
|
822
1334
|
}
|
|
@@ -827,9 +1339,78 @@ var NetSession = class {
|
|
|
827
1339
|
const peerIds = [...this.#peerIds].sort((left, right) => left - right);
|
|
828
1340
|
return { peerIds, connected: peerIds.length > 0 };
|
|
829
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
|
+
}
|
|
830
1389
|
sendRaw(peerId, data) {
|
|
831
|
-
|
|
832
|
-
return
|
|
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);
|
|
833
1414
|
}
|
|
834
1415
|
attachAuthority(authority) {
|
|
835
1416
|
this.#authority = authority;
|
|
@@ -837,30 +1418,183 @@ var NetSession = class {
|
|
|
837
1418
|
requestFullBaseline(peerId) {
|
|
838
1419
|
if (this.#peerIds.has(peerId)) this.#pendingFullPeers.add(peerId);
|
|
839
1420
|
}
|
|
1421
|
+
requestFullBaselineForSession(sessionId) {
|
|
1422
|
+
const peerId = this.#sessionPeers.get(sessionId);
|
|
1423
|
+
if (peerId !== void 0) this.requestFullBaseline(peerId);
|
|
1424
|
+
}
|
|
840
1425
|
attachReplica(coordinator, limits) {
|
|
841
1426
|
this.#replica = { coordinator, limits };
|
|
842
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
|
+
}
|
|
843
1473
|
publish() {
|
|
844
|
-
if (this.#authority === void 0
|
|
1474
|
+
if (this.#authority === void 0 || this.#endpoint === void 0 || this.#peerIds.size === 0)
|
|
1475
|
+
return ok(void 0);
|
|
845
1476
|
if (this.#pendingFullPeers.size > 0) {
|
|
1477
|
+
const capacity2 = this.#ensurePublicationCapacity(this.#authority.nextPublicationEpoch(true));
|
|
1478
|
+
if (!capacity2.ok) return capacity2;
|
|
846
1479
|
const published2 = this.#authority.publishFull();
|
|
847
1480
|
if (!published2.ok) return err(published2.error);
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
const sent = this.#endpoint.send(peerId, published2.value.bytes);
|
|
851
|
-
if (!sent.ok) return err(sent.error);
|
|
852
|
-
}
|
|
853
|
-
}
|
|
1481
|
+
const sent2 = this.#sendPublished(published2.value, [...this.#peerIds]);
|
|
1482
|
+
if (!sent2.ok) return err(sent2.error);
|
|
854
1483
|
this.#pendingFullPeers.clear();
|
|
1484
|
+
return ok(void 0);
|
|
855
1485
|
}
|
|
1486
|
+
const capacity = this.#ensurePublicationCapacity(this.#authority.nextPublicationEpoch());
|
|
1487
|
+
if (!capacity.ok) return capacity;
|
|
856
1488
|
const published = this.#authority.publish();
|
|
857
1489
|
if (!published.ok) return err(published.error);
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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;
|
|
861
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;
|
|
862
1572
|
return ok(void 0);
|
|
863
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
|
+
}
|
|
864
1598
|
};
|
|
865
1599
|
function netPlugin(config) {
|
|
866
1600
|
return {
|
|
@@ -869,12 +1603,23 @@ function netPlugin(config) {
|
|
|
869
1603
|
apply(ctx) {
|
|
870
1604
|
const world = ctx.world;
|
|
871
1605
|
const session = new NetSession({
|
|
872
|
-
endpoint: config.endpoint,
|
|
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 },
|
|
873
1611
|
maxRawMessages: config.maxRawMessages ?? 256
|
|
874
1612
|
});
|
|
875
1613
|
ctx.effect(() => {
|
|
876
1614
|
world.insertResource("net-session", session);
|
|
877
|
-
|
|
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
|
+
};
|
|
878
1623
|
}, "net/session-resource");
|
|
879
1624
|
ctx.effect(() => {
|
|
880
1625
|
world.addSystem(Update, {
|
|
@@ -898,6 +1643,6 @@ function netPlugin(config) {
|
|
|
898
1643
|
};
|
|
899
1644
|
}
|
|
900
1645
|
|
|
901
|
-
export { AuthorityCoordinator, ENDPOINT_ERROR_HINTS, ENDPOINT_EXPECTED, EndpointError, NetError, NetSession, ReplicaCoordinator,
|
|
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 };
|
|
902
1647
|
//# sourceMappingURL=index.mjs.map
|
|
903
1648
|
//# sourceMappingURL=index.mjs.map
|