@forgeax/engine-net 0.1.3 → 0.1.6
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 +860 -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 +88 -28
- package/src/session/net-session.ts +612 -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,20 @@ 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) {
|
|
630
746
|
if (this.#stopped)
|
|
631
747
|
return new NetError({
|
|
632
748
|
code: "apply-invariant-failed",
|
|
@@ -634,22 +750,54 @@ var ReplicaCoordinator = class {
|
|
|
634
750
|
hint: "create a new session after a fatal apply failure",
|
|
635
751
|
detail: { reason: "replication stopped" }
|
|
636
752
|
});
|
|
637
|
-
if (
|
|
753
|
+
if (packet.fingerprint !== this.#profile.fingerprint)
|
|
638
754
|
return new NetError({
|
|
639
755
|
code: "schema-invalid",
|
|
640
756
|
expected: "a batch for the negotiated replication profile",
|
|
641
757
|
hint: "complete handshake before applying replication bytes",
|
|
642
758
|
detail: { component: "", reason: "fingerprint mismatch" }
|
|
643
759
|
});
|
|
644
|
-
|
|
760
|
+
const newEpoch = packet.epoch > this.#epoch;
|
|
761
|
+
if (this.#epoch < 0 && packet.kind !== "baseline")
|
|
762
|
+
return new NetError({
|
|
763
|
+
code: "session-illegal-transition",
|
|
764
|
+
expected: "a baseline before any delta in a session epoch",
|
|
765
|
+
hint: "accept a complete authoritative baseline before applying deltas",
|
|
766
|
+
detail: { from: "connecting", to: packet.kind }
|
|
767
|
+
});
|
|
768
|
+
if (packet.epoch > this.#epoch && (packet.kind !== "baseline" || packet.sequence !== 1))
|
|
769
|
+
return new NetError({
|
|
770
|
+
code: "session-illegal-transition",
|
|
771
|
+
expected: "a sequence-one baseline at the start of a new epoch",
|
|
772
|
+
hint: "request a fresh baseline before applying the next delta",
|
|
773
|
+
detail: { from: "resyncing", to: packet.kind }
|
|
774
|
+
});
|
|
775
|
+
if (packet.epoch < this.#epoch) return null;
|
|
776
|
+
if (packet.kind === "baseline" && !newEpoch && this.#lastSequence >= 1) {
|
|
777
|
+
this.#lastPacketOutcome = "duplicate";
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
if (packet.kind === "delta" && packet.sequence <= this.#lastSequence) {
|
|
781
|
+
this.#lastPacketOutcome = "duplicate";
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
if (packet.kind === "delta" && packet.sequence !== this.#lastSequence + 1)
|
|
785
|
+
return new NetError({
|
|
786
|
+
code: "ordering-invalid-tick",
|
|
787
|
+
expected: "the next contiguous replication sequence",
|
|
788
|
+
hint: "request a fresh baseline when a sequence gap is detected",
|
|
789
|
+
detail: { receivedTick: packet.sequence, lastTick: this.#lastSequence }
|
|
790
|
+
});
|
|
791
|
+
if (!newEpoch && packet.tick <= this.#lastTick)
|
|
645
792
|
return new NetError({
|
|
646
793
|
code: "ordering-invalid-tick",
|
|
647
794
|
expected: "a strictly monotonic authority tick",
|
|
648
795
|
hint: "discard duplicate, stale, and out-of-order batches",
|
|
649
|
-
detail: { receivedTick:
|
|
796
|
+
detail: { receivedTick: packet.tick, lastTick: this.#lastTick }
|
|
650
797
|
});
|
|
651
798
|
const batchIds = /* @__PURE__ */ new Set();
|
|
652
|
-
|
|
799
|
+
const knownIds = newEpoch ? /* @__PURE__ */ new Set() : new Set(this.#entities.keys());
|
|
800
|
+
for (const record of packet.entities) {
|
|
653
801
|
if (!Number.isSafeInteger(record.id) || record.id <= 0 || batchIds.has(record.id))
|
|
654
802
|
return new NetError({
|
|
655
803
|
code: "identity-invalid",
|
|
@@ -659,8 +807,8 @@ var ReplicaCoordinator = class {
|
|
|
659
807
|
});
|
|
660
808
|
batchIds.add(record.id);
|
|
661
809
|
}
|
|
662
|
-
for (const record of
|
|
663
|
-
if (record.kind === "despawn" && !
|
|
810
|
+
for (const record of packet.entities) {
|
|
811
|
+
if (record.kind === "despawn" && !knownIds.has(record.id))
|
|
664
812
|
return new NetError({
|
|
665
813
|
code: "identity-invalid",
|
|
666
814
|
expected: "a known identity for despawn",
|
|
@@ -690,7 +838,7 @@ var ReplicaCoordinator = class {
|
|
|
690
838
|
const kind = classifyEntityField(component, field);
|
|
691
839
|
const refs = kind?.isArray ? this.#entityReferences(value) : kind ? [value] : [];
|
|
692
840
|
for (const reference of refs)
|
|
693
|
-
if (reference !== null && (typeof reference !== "number" || reference === 0 || !
|
|
841
|
+
if (reference !== null && (typeof reference !== "number" || reference === 0 || !knownIds.has(reference) && !batchIds.has(reference)))
|
|
694
842
|
return new NetError({
|
|
695
843
|
code: "remap-unresolved-reference",
|
|
696
844
|
expected: "every entity reference to resolve in the current or same batch",
|
|
@@ -702,17 +850,26 @@ var ReplicaCoordinator = class {
|
|
|
702
850
|
}
|
|
703
851
|
return null;
|
|
704
852
|
}
|
|
705
|
-
apply(
|
|
706
|
-
const failure = this.validate(
|
|
853
|
+
apply(packet) {
|
|
854
|
+
const failure = this.validate(packet);
|
|
707
855
|
if (failure) {
|
|
708
|
-
this.disconnect();
|
|
709
856
|
return err(failure);
|
|
710
857
|
}
|
|
858
|
+
if (packet.epoch < this.#epoch) {
|
|
859
|
+
this.#lastPacketOutcome = "ignored-old-epoch";
|
|
860
|
+
return ok(void 0);
|
|
861
|
+
}
|
|
862
|
+
if (this.#lastPacketOutcome === "duplicate") return ok(void 0);
|
|
863
|
+
const replacingEpoch = packet.epoch > this.#epoch;
|
|
711
864
|
try {
|
|
712
|
-
|
|
865
|
+
if (replacingEpoch) {
|
|
866
|
+
for (const entity of this.#entities.values()) this.#world.despawn(entity).unwrap();
|
|
867
|
+
this.#entities.clear();
|
|
868
|
+
}
|
|
869
|
+
for (const record of packet.entities)
|
|
713
870
|
if (record.kind === "upsert" && !this.#entities.has(record.id))
|
|
714
871
|
this.#entities.set(record.id, this.#world.spawn().unwrap());
|
|
715
|
-
for (const record of
|
|
872
|
+
for (const record of packet.entities)
|
|
716
873
|
if (record.kind === "upsert") {
|
|
717
874
|
const entity = this.#entities.get(record.id);
|
|
718
875
|
if (entity === void 0) throw new Error(`missing allocated entity ${record.id}`);
|
|
@@ -747,14 +904,17 @@ var ReplicaCoordinator = class {
|
|
|
747
904
|
if (!write.ok) throw write.error;
|
|
748
905
|
}
|
|
749
906
|
}
|
|
750
|
-
for (const record of
|
|
907
|
+
for (const record of packet.entities)
|
|
751
908
|
if (record.kind === "despawn") {
|
|
752
909
|
const entity = this.#entities.get(record.id);
|
|
753
910
|
if (entity === void 0) throw new Error(`missing despawn entity ${record.id}`);
|
|
754
911
|
this.#world.despawn(entity).unwrap();
|
|
755
912
|
this.#entities.delete(record.id);
|
|
756
913
|
}
|
|
757
|
-
this.#
|
|
914
|
+
this.#epoch = packet.epoch;
|
|
915
|
+
this.#lastSequence = packet.sequence;
|
|
916
|
+
this.#lastTick = packet.tick;
|
|
917
|
+
this.#lastPacketOutcome = "accepted";
|
|
758
918
|
return ok(void 0);
|
|
759
919
|
} catch (cause) {
|
|
760
920
|
this.#stopped = true;
|
|
@@ -772,51 +932,401 @@ var ReplicaCoordinator = class {
|
|
|
772
932
|
function createReplicaCoordinator(world, profile, endpoint) {
|
|
773
933
|
return new ReplicaCoordinator(world, profile, endpoint);
|
|
774
934
|
}
|
|
775
|
-
function
|
|
776
|
-
return replica.apply(
|
|
935
|
+
function applyReplicationPacket(replica, packet) {
|
|
936
|
+
return replica.apply(packet);
|
|
777
937
|
}
|
|
778
|
-
function
|
|
779
|
-
const decoded =
|
|
938
|
+
function decodeAndApplyReplicationPacket(replica, bytes, limits) {
|
|
939
|
+
const decoded = decodeReplicationPacket(bytes, limits);
|
|
780
940
|
if (!decoded.ok) {
|
|
781
|
-
replica.disconnect();
|
|
782
941
|
return err(decoded.error);
|
|
783
942
|
}
|
|
943
|
+
if (decoded.value.kind !== "baseline" && decoded.value.kind !== "delta") {
|
|
944
|
+
return err(
|
|
945
|
+
new NetError({
|
|
946
|
+
code: "decode-invalid-payload",
|
|
947
|
+
expected: "a baseline or delta replication packet",
|
|
948
|
+
hint: "apply only data packets through the replica coordinator",
|
|
949
|
+
detail: { reason: "control packet cannot be applied as ECS data" }
|
|
950
|
+
})
|
|
951
|
+
);
|
|
952
|
+
}
|
|
784
953
|
return replica.apply(decoded.value);
|
|
785
954
|
}
|
|
955
|
+
var DEFAULT_NET_RECOVERY_POLICY = Object.freeze({
|
|
956
|
+
maxSessions: 64,
|
|
957
|
+
maxPendingPackets: 32,
|
|
958
|
+
ackTimeoutMs: 250,
|
|
959
|
+
maxPacketRetries: 3,
|
|
960
|
+
maxReconnectAttempts: 5,
|
|
961
|
+
reconnectDeadlineMs: 1e4,
|
|
962
|
+
reconnectDelaysMs: Object.freeze([0, 100, 200, 400, 800])
|
|
963
|
+
});
|
|
964
|
+
function policyError(field, reason) {
|
|
965
|
+
return new NetError({
|
|
966
|
+
code: "recovery-policy-invalid",
|
|
967
|
+
expected: "finite positive recovery policy bounds",
|
|
968
|
+
hint: "provide positive safe integers and a finite non-negative delay sequence",
|
|
969
|
+
detail: { field, reason }
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
function isPositiveSafeInteger(value) {
|
|
973
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
974
|
+
}
|
|
975
|
+
function validateNetRecoveryPolicy(policy) {
|
|
976
|
+
const positiveFields = [
|
|
977
|
+
"maxSessions",
|
|
978
|
+
"maxPendingPackets",
|
|
979
|
+
"ackTimeoutMs",
|
|
980
|
+
"maxPacketRetries",
|
|
981
|
+
"maxReconnectAttempts",
|
|
982
|
+
"reconnectDeadlineMs"
|
|
983
|
+
];
|
|
984
|
+
for (const field of positiveFields) {
|
|
985
|
+
if (!isPositiveSafeInteger(policy[field]))
|
|
986
|
+
return err(policyError(field, "value must be a positive safe integer"));
|
|
987
|
+
}
|
|
988
|
+
if (!Array.isArray(policy.reconnectDelaysMs) || policy.reconnectDelaysMs.length === 0 || policy.reconnectDelaysMs.some((delay) => !Number.isSafeInteger(delay) || delay < 0))
|
|
989
|
+
return err(
|
|
990
|
+
policyError("reconnectDelaysMs", "values must be a non-empty finite delay sequence")
|
|
991
|
+
);
|
|
992
|
+
return ok(void 0);
|
|
993
|
+
}
|
|
994
|
+
function resolveNetRecoveryPolicy(overrides = {}) {
|
|
995
|
+
const policy = {
|
|
996
|
+
...DEFAULT_NET_RECOVERY_POLICY,
|
|
997
|
+
...overrides,
|
|
998
|
+
reconnectDelaysMs: overrides.reconnectDelaysMs === void 0 ? DEFAULT_NET_RECOVERY_POLICY.reconnectDelaysMs : [...overrides.reconnectDelaysMs]
|
|
999
|
+
};
|
|
1000
|
+
const valid = validateNetRecoveryPolicy(policy);
|
|
1001
|
+
return valid.ok ? ok(Object.freeze(policy)) : err(valid.error);
|
|
1002
|
+
}
|
|
1003
|
+
function createSessionId(value) {
|
|
1004
|
+
if (!isPositiveSafeInteger(value))
|
|
1005
|
+
return err(
|
|
1006
|
+
new NetError({
|
|
1007
|
+
code: "recovery-policy-invalid",
|
|
1008
|
+
expected: "a positive safe integer SessionId",
|
|
1009
|
+
hint: "use the authority-issued application session identity",
|
|
1010
|
+
detail: { field: "sessionId", reason: "SessionId must be a positive safe integer" }
|
|
1011
|
+
})
|
|
1012
|
+
);
|
|
1013
|
+
return ok(value);
|
|
1014
|
+
}
|
|
1015
|
+
var LEGAL_TRANSITIONS = {
|
|
1016
|
+
connecting: ["recovering", "resyncing", "failed", "retired"],
|
|
1017
|
+
resyncing: ["active", "recovering", "failed", "retired"],
|
|
1018
|
+
active: ["active", "recovering", "failed", "retired"],
|
|
1019
|
+
recovering: ["recovering", "resyncing", "failed", "retired"],
|
|
1020
|
+
failed: ["retired"],
|
|
1021
|
+
retired: ["retired"]
|
|
1022
|
+
};
|
|
1023
|
+
function isLegalNetSessionTransition(from, to) {
|
|
1024
|
+
return LEGAL_TRANSITIONS[from].includes(to);
|
|
1025
|
+
}
|
|
1026
|
+
function transitionNetSessionState(from, to) {
|
|
1027
|
+
if (from.sessionId !== to.sessionId || !isLegalNetSessionTransition(from.kind, to.kind))
|
|
1028
|
+
return err(
|
|
1029
|
+
new NetError({
|
|
1030
|
+
code: "session-illegal-transition",
|
|
1031
|
+
expected: "a legal transition for the same SessionId",
|
|
1032
|
+
hint: "wait for the current session state or retire the session before replacing it",
|
|
1033
|
+
detail: { from: from.kind, to: to.kind }
|
|
1034
|
+
})
|
|
1035
|
+
);
|
|
1036
|
+
return ok(to);
|
|
1037
|
+
}
|
|
1038
|
+
var RECOVERY_ERROR_CODES = [
|
|
1039
|
+
"protocol-unsupported-version",
|
|
1040
|
+
"session-illegal-transition",
|
|
1041
|
+
"recovery-policy-invalid",
|
|
1042
|
+
"recovery-rejected",
|
|
1043
|
+
"recovery-exhausted"
|
|
1044
|
+
];
|
|
1045
|
+
|
|
1046
|
+
// src/session/net-session.ts
|
|
1047
|
+
var defaultClock = {
|
|
1048
|
+
now: () => Date.now(),
|
|
1049
|
+
schedule: (delayMs, callback) => {
|
|
1050
|
+
const id = globalThis.setTimeout(callback, delayMs);
|
|
1051
|
+
return { cancel: () => globalThis.clearTimeout(id) };
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
function recoveryFailure(reason) {
|
|
1055
|
+
return new NetError({
|
|
1056
|
+
code: "recovery-rejected",
|
|
1057
|
+
expected: "a recoverable NetSession lifecycle operation",
|
|
1058
|
+
hint: "inspect the current snapshot and retire the session after terminal failure",
|
|
1059
|
+
detail: { reason }
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
function initialState(sessionId, endpoint) {
|
|
1063
|
+
return endpoint === void 0 ? { kind: "connecting", sessionId } : { kind: "resyncing", sessionId, epoch: 0 };
|
|
1064
|
+
}
|
|
786
1065
|
var NetSession = class {
|
|
787
1066
|
#endpoint;
|
|
1067
|
+
#connector;
|
|
1068
|
+
#clock;
|
|
1069
|
+
#policy;
|
|
1070
|
+
#sessionId;
|
|
788
1071
|
#peerIds = /* @__PURE__ */ new Set();
|
|
1072
|
+
#sessionPeers = /* @__PURE__ */ new Map();
|
|
1073
|
+
#announcedPeers = /* @__PURE__ */ new Set();
|
|
1074
|
+
#sessionAnnounced = false;
|
|
789
1075
|
#rawMessages = [];
|
|
790
1076
|
#maxRawMessages;
|
|
791
1077
|
#authority;
|
|
792
1078
|
#pendingFullPeers = /* @__PURE__ */ new Set();
|
|
793
1079
|
#replica;
|
|
1080
|
+
#state;
|
|
1081
|
+
#lastError;
|
|
1082
|
+
#epoch = 0;
|
|
1083
|
+
#sequence = 0;
|
|
1084
|
+
#acknowledgedSequence = 0;
|
|
1085
|
+
#reconnectAttempts = 0;
|
|
1086
|
+
#pendingConnect;
|
|
1087
|
+
#retryTimer;
|
|
1088
|
+
#ledger = /* @__PURE__ */ new Map();
|
|
1089
|
+
#disposed = false;
|
|
794
1090
|
constructor(config) {
|
|
795
1091
|
this.#endpoint = config.endpoint;
|
|
1092
|
+
this.#connector = config.connector;
|
|
1093
|
+
this.#clock = config.clock ?? defaultClock;
|
|
796
1094
|
this.#maxRawMessages = config.maxRawMessages;
|
|
1095
|
+
const resolvedSessionId = this.#resolveSessionId(config.sessionId);
|
|
1096
|
+
this.#sessionId = resolvedSessionId.ok ? resolvedSessionId.value : 1;
|
|
1097
|
+
const policy = resolveNetRecoveryPolicy(config.recovery);
|
|
1098
|
+
this.#policy = policy.ok ? policy.value : DEFAULT_NET_RECOVERY_POLICY;
|
|
1099
|
+
this.#state = initialState(this.#sessionId, this.#endpoint);
|
|
1100
|
+
if (!resolvedSessionId.ok) this.#setFailure(resolvedSessionId.error);
|
|
1101
|
+
else if (!policy.ok) this.#setFailure(policy.error);
|
|
1102
|
+
}
|
|
1103
|
+
#resolveSessionId(value) {
|
|
1104
|
+
return createSessionId(value ?? 1);
|
|
1105
|
+
}
|
|
1106
|
+
#setState(next) {
|
|
1107
|
+
const transition = transitionNetSessionState(this.#state, next);
|
|
1108
|
+
if (transition.ok) {
|
|
1109
|
+
this.#state = transition.value;
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
this.#setFailure(transition.error);
|
|
1113
|
+
}
|
|
1114
|
+
#setFailure(failure) {
|
|
1115
|
+
this.#lastError = failure;
|
|
1116
|
+
if (this.#state.kind !== "failed" && this.#state.kind !== "retired")
|
|
1117
|
+
this.#setState({ kind: "failed", sessionId: this.#sessionId, error: failure });
|
|
1118
|
+
this.#authority = void 0;
|
|
1119
|
+
this.#peerIds.clear();
|
|
1120
|
+
this.#sessionPeers.clear();
|
|
1121
|
+
this.#announcedPeers.clear();
|
|
1122
|
+
this.#sessionAnnounced = false;
|
|
1123
|
+
this.#pendingFullPeers.clear();
|
|
1124
|
+
this.#rawMessages = [];
|
|
1125
|
+
this.#clearRecoveryWork();
|
|
1126
|
+
this.#endpoint?.close();
|
|
1127
|
+
}
|
|
1128
|
+
#clearRecoveryWork() {
|
|
1129
|
+
this.#retryTimer?.cancel();
|
|
1130
|
+
this.#retryTimer = void 0;
|
|
1131
|
+
this.#pendingConnect?.abort();
|
|
1132
|
+
this.#pendingConnect = void 0;
|
|
1133
|
+
this.#ledger.clear();
|
|
1134
|
+
}
|
|
1135
|
+
#beginRecovery() {
|
|
1136
|
+
const previousEndpoint = this.#endpoint;
|
|
1137
|
+
this.#endpoint = void 0;
|
|
1138
|
+
previousEndpoint?.close();
|
|
1139
|
+
if (this.#state.kind === "connecting" || this.#state.kind === "active" || this.#state.kind === "resyncing")
|
|
1140
|
+
this.#setState({
|
|
1141
|
+
kind: "recovering",
|
|
1142
|
+
sessionId: this.#sessionId,
|
|
1143
|
+
epoch: this.#epoch,
|
|
1144
|
+
attempt: 0
|
|
1145
|
+
});
|
|
1146
|
+
this.#ledger.clear();
|
|
1147
|
+
this.#sequence = 0;
|
|
1148
|
+
this.#acknowledgedSequence = 0;
|
|
1149
|
+
this.#peerIds.clear();
|
|
1150
|
+
this.#sessionPeers.clear();
|
|
1151
|
+
this.#announcedPeers.clear();
|
|
1152
|
+
this.#sessionAnnounced = false;
|
|
1153
|
+
this.#pendingFullPeers.clear();
|
|
1154
|
+
this.#rawMessages = [];
|
|
1155
|
+
}
|
|
1156
|
+
#attemptRecovery() {
|
|
1157
|
+
if (this.#disposed || this.#state.kind !== "recovering" || this.#pendingConnect) return;
|
|
1158
|
+
if (this.#reconnectAttempts >= this.#policy.maxReconnectAttempts) {
|
|
1159
|
+
this.#setFailure(
|
|
1160
|
+
new NetError({
|
|
1161
|
+
code: "recovery-exhausted",
|
|
1162
|
+
expected: "reconnect attempts within the configured finite bound",
|
|
1163
|
+
hint: "inspect the failure and create a new session after exhaustion",
|
|
1164
|
+
detail: {
|
|
1165
|
+
attempts: this.#reconnectAttempts,
|
|
1166
|
+
maxAttempts: this.#policy.maxReconnectAttempts
|
|
1167
|
+
}
|
|
1168
|
+
})
|
|
1169
|
+
);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
this.#reconnectAttempts += 1;
|
|
1173
|
+
this.#setState({
|
|
1174
|
+
kind: "recovering",
|
|
1175
|
+
sessionId: this.#sessionId,
|
|
1176
|
+
epoch: this.#epoch,
|
|
1177
|
+
attempt: this.#reconnectAttempts
|
|
1178
|
+
});
|
|
1179
|
+
if (this.#connector === void 0) {
|
|
1180
|
+
if (this.#reconnectAttempts >= this.#policy.maxReconnectAttempts) this.#attemptRecovery();
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
const controller = new AbortController();
|
|
1184
|
+
this.#pendingConnect = { abort: () => controller.abort() };
|
|
1185
|
+
void this.#connector.connect(controller.signal).then(
|
|
1186
|
+
(result) => this.#connected(result),
|
|
1187
|
+
(cause) => this.#connectFailed(cause)
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
#connected(result) {
|
|
1191
|
+
this.#pendingConnect = void 0;
|
|
1192
|
+
if (this.#disposed || this.#state.kind !== "recovering") {
|
|
1193
|
+
if (result.ok) result.value.close();
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
if (!result.ok) {
|
|
1197
|
+
this.#connectFailed(result.error);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
this.#endpoint?.close();
|
|
1201
|
+
this.#endpoint = result.value;
|
|
1202
|
+
this.#lastError = void 0;
|
|
1203
|
+
this.#epoch += 1;
|
|
1204
|
+
this.#sequence = 0;
|
|
1205
|
+
this.#acknowledgedSequence = 0;
|
|
1206
|
+
this.#ledger.clear();
|
|
1207
|
+
this.#setState({ kind: "resyncing", sessionId: this.#sessionId, epoch: this.#epoch });
|
|
1208
|
+
}
|
|
1209
|
+
#connectFailed(cause) {
|
|
1210
|
+
this.#pendingConnect = void 0;
|
|
1211
|
+
if (this.#disposed || this.#state.kind !== "recovering") return;
|
|
1212
|
+
const failure = cause instanceof NetError ? cause : isEndpointError(cause) ? cause : recoveryFailure("connector attempt failed");
|
|
1213
|
+
if (this.#reconnectAttempts >= this.#policy.maxReconnectAttempts) {
|
|
1214
|
+
this.#setFailure(
|
|
1215
|
+
new NetError({
|
|
1216
|
+
code: "recovery-exhausted",
|
|
1217
|
+
expected: "reconnect attempts within the configured finite bound",
|
|
1218
|
+
hint: "inspect the endpoint failure and create a new session after exhaustion",
|
|
1219
|
+
detail: {
|
|
1220
|
+
attempts: this.#reconnectAttempts,
|
|
1221
|
+
maxAttempts: this.#policy.maxReconnectAttempts
|
|
1222
|
+
}
|
|
1223
|
+
})
|
|
1224
|
+
);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
this.#lastError = failure;
|
|
1228
|
+
this.advanceRecovery();
|
|
1229
|
+
}
|
|
1230
|
+
#handleAck(packet) {
|
|
1231
|
+
if (packet.sessionId !== this.#sessionId && !this.#sessionPeers.has(packet.sessionId))
|
|
1232
|
+
return err(
|
|
1233
|
+
new NetError({
|
|
1234
|
+
code: "recovery-rejected",
|
|
1235
|
+
expected: "an ACK for the current SessionId",
|
|
1236
|
+
hint: "discard ACKs from another logical session",
|
|
1237
|
+
detail: { reason: "ACK SessionId does not match the current session" }
|
|
1238
|
+
})
|
|
1239
|
+
);
|
|
1240
|
+
if (packet.epoch !== this.#epoch || packet.acknowledgedSequence > this.#sequence)
|
|
1241
|
+
return ok(void 0);
|
|
1242
|
+
if (packet.acknowledgedSequence <= this.#acknowledgedSequence) return ok(void 0);
|
|
1243
|
+
this.#acknowledgedSequence = packet.acknowledgedSequence;
|
|
1244
|
+
for (const sequence of this.#ledger.keys())
|
|
1245
|
+
if (sequence <= packet.acknowledgedSequence) this.#ledger.delete(sequence);
|
|
1246
|
+
return ok(void 0);
|
|
1247
|
+
}
|
|
1248
|
+
#receiveMessage(peerId, data, errors) {
|
|
1249
|
+
if (this.#state.kind === "recovering") return;
|
|
1250
|
+
const limits = this.#replica?.limits ?? DEFAULT_REPLICATION_LIMITS;
|
|
1251
|
+
const decoded = decodeReplicationPacket(data, limits);
|
|
1252
|
+
if (!decoded.ok) {
|
|
1253
|
+
if (this.#replica === void 0) {
|
|
1254
|
+
this.#queueRawMessage(peerId, data);
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
errors.push(decoded.error);
|
|
1258
|
+
this.#setFailure(decoded.error);
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
if (decoded.value.kind === "session-open" || decoded.value.kind === "session-resume") {
|
|
1262
|
+
this.#bindSession(decoded.value.sessionId, peerId);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
if (decoded.value.kind === "ack") {
|
|
1266
|
+
const handled = this.#handleAck(decoded.value);
|
|
1267
|
+
if (!handled.ok) {
|
|
1268
|
+
errors.push(handled.error);
|
|
1269
|
+
this.#setFailure(handled.error);
|
|
1270
|
+
}
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (decoded.value.kind !== "baseline" && decoded.value.kind !== "delta") {
|
|
1274
|
+
if (decoded.value.kind === "rejection") {
|
|
1275
|
+
const failure = recoveryFailure(
|
|
1276
|
+
`peer rejected ${decoded.value.rejectedKind}: ${decoded.value.reason}`
|
|
1277
|
+
);
|
|
1278
|
+
errors.push(failure);
|
|
1279
|
+
this.#setFailure(failure);
|
|
1280
|
+
}
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
if (this.#replica === void 0) {
|
|
1284
|
+
this.#queueRawMessage(peerId, data);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
const applied = decodeAndApplyReplicationPacket(
|
|
1288
|
+
this.#replica.coordinator,
|
|
1289
|
+
data,
|
|
1290
|
+
this.#replica.limits
|
|
1291
|
+
);
|
|
1292
|
+
if (!applied.ok) {
|
|
1293
|
+
errors.push(applied.error);
|
|
1294
|
+
this.#setFailure(applied.error);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
const packetOutcome = this.#replica.coordinator.lastPacketOutcome;
|
|
1298
|
+
if (packetOutcome === "accepted") {
|
|
1299
|
+
this.#epoch = decoded.value.epoch;
|
|
1300
|
+
this.#sequence = decoded.value.sequence;
|
|
1301
|
+
this.#acknowledgedSequence = decoded.value.sequence;
|
|
1302
|
+
this.#setState({
|
|
1303
|
+
kind: "active",
|
|
1304
|
+
sessionId: this.#sessionId,
|
|
1305
|
+
epoch: this.#epoch,
|
|
1306
|
+
sequence: this.#sequence
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
if (packetOutcome === "accepted" || packetOutcome === "duplicate")
|
|
1310
|
+
this.#sendReplicationAck(peerId, decoded.value);
|
|
797
1311
|
}
|
|
798
1312
|
receiveEvents() {
|
|
799
1313
|
const errors = [];
|
|
800
|
-
|
|
1314
|
+
if (this.#disposed || this.#state.kind === "failed" || this.#state.kind === "retired")
|
|
1315
|
+
return errors;
|
|
1316
|
+
for (const event of this.#endpoint?.poll() ?? []) {
|
|
801
1317
|
if (event.kind === "peer-connected") {
|
|
802
1318
|
this.#peerIds.add(event.peerId);
|
|
1319
|
+
if (this.#replica !== void 0) this.#bindSession(this.#sessionId, event.peerId);
|
|
1320
|
+
else this.#bindSession(this.#sessionForPeer(event.peerId), event.peerId);
|
|
803
1321
|
this.#pendingFullPeers.add(event.peerId);
|
|
804
1322
|
} else if (event.kind === "peer-disconnected") {
|
|
805
|
-
this.#
|
|
806
|
-
this.#pendingFullPeers.delete(event.peerId);
|
|
807
|
-
this.#replica?.coordinator.clear();
|
|
808
|
-
} else {
|
|
1323
|
+
this.#forgetPeer(event.peerId);
|
|
809
1324
|
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 });
|
|
1325
|
+
this.#replica.coordinator.clear();
|
|
1326
|
+
this.#beginRecovery();
|
|
1327
|
+
this.advanceRecovery();
|
|
818
1328
|
}
|
|
819
|
-
}
|
|
1329
|
+
} else this.#receiveMessage(event.peerId, event.data, errors);
|
|
820
1330
|
}
|
|
821
1331
|
return errors;
|
|
822
1332
|
}
|
|
@@ -827,9 +1337,78 @@ var NetSession = class {
|
|
|
827
1337
|
const peerIds = [...this.#peerIds].sort((left, right) => left - right);
|
|
828
1338
|
return { peerIds, connected: peerIds.length > 0 };
|
|
829
1339
|
}
|
|
1340
|
+
getSessionSnapshot() {
|
|
1341
|
+
const sessionIds = [...this.#sessionPeers.keys()].sort((left, right) => left - right);
|
|
1342
|
+
return { sessionIds, connected: sessionIds.length > 0 };
|
|
1343
|
+
}
|
|
1344
|
+
/** Return lifecycle, epoch, sequence, ledger, and owned-resource evidence. */
|
|
1345
|
+
getRecoverySnapshot() {
|
|
1346
|
+
return {
|
|
1347
|
+
sessionId: this.#sessionId,
|
|
1348
|
+
state: this.#state,
|
|
1349
|
+
pendingPackets: this.#ledger.size,
|
|
1350
|
+
maxPendingPackets: this.#policy.maxPendingPackets,
|
|
1351
|
+
acknowledgedSequence: this.#acknowledgedSequence,
|
|
1352
|
+
reconnectAttempts: this.#reconnectAttempts,
|
|
1353
|
+
epoch: this.#epoch,
|
|
1354
|
+
sequence: this.#sequence,
|
|
1355
|
+
...this.#lastError === void 0 ? {} : { lastError: this.#lastError },
|
|
1356
|
+
ownedResources: {
|
|
1357
|
+
pendingConnects: this.#pendingConnect === void 0 ? 0 : 1,
|
|
1358
|
+
timers: this.#retryTimer === void 0 ? 0 : 1,
|
|
1359
|
+
ledgers: this.#ledger.size === 0 ? 0 : 1,
|
|
1360
|
+
callbacks: 0
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
getResourceSnapshot() {
|
|
1365
|
+
return this.getRecoverySnapshot().ownedResources;
|
|
1366
|
+
}
|
|
1367
|
+
recover() {
|
|
1368
|
+
if (this.#state.kind === "retired" || this.#state.kind === "failed")
|
|
1369
|
+
return { kind: "retired", sessionId: this.#sessionId };
|
|
1370
|
+
if (this.#state.kind === "recovering")
|
|
1371
|
+
return { kind: "already-recovering", sessionId: this.#sessionId };
|
|
1372
|
+
this.#beginRecovery();
|
|
1373
|
+
return { kind: "started", sessionId: this.#sessionId };
|
|
1374
|
+
}
|
|
1375
|
+
advanceRecovery() {
|
|
1376
|
+
if (this.#state.kind !== "recovering") return;
|
|
1377
|
+
const delay = this.#policy.reconnectDelaysMs[Math.min(this.#reconnectAttempts, this.#policy.reconnectDelaysMs.length - 1)];
|
|
1378
|
+
if (delay === void 0 || delay === 0) this.#attemptRecovery();
|
|
1379
|
+
else {
|
|
1380
|
+
this.#retryTimer?.cancel();
|
|
1381
|
+
this.#retryTimer = this.#clock.schedule(delay, () => {
|
|
1382
|
+
this.#retryTimer = void 0;
|
|
1383
|
+
this.#attemptRecovery();
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
830
1387
|
sendRaw(peerId, data) {
|
|
831
|
-
|
|
832
|
-
return
|
|
1388
|
+
if (this.#state.kind !== "active") return err(recoveryFailure("session is not active"));
|
|
1389
|
+
return this.#sendToPeer(peerId, data);
|
|
1390
|
+
}
|
|
1391
|
+
/** Send one application command through the current replica attachment. */
|
|
1392
|
+
sendToAuthority(sessionId, data) {
|
|
1393
|
+
if (sessionId !== this.#sessionId)
|
|
1394
|
+
return err(recoveryFailure("session id does not belong to this NetSession"));
|
|
1395
|
+
if (this.#state.kind === "recovering" || this.#state.kind === "failed" || this.#state.kind === "retired")
|
|
1396
|
+
return err(recoveryFailure("session is not connected to the authority"));
|
|
1397
|
+
const peerId = this.#peerForSession(sessionId);
|
|
1398
|
+
if (peerId === void 0) return err(recoveryFailure("authority peer is not connected"));
|
|
1399
|
+
if (this.#replica !== void 0) {
|
|
1400
|
+
const announced = this.#announceSession(peerId);
|
|
1401
|
+
if (!announced.ok) return announced;
|
|
1402
|
+
}
|
|
1403
|
+
return this.#sendToPeer(peerId, data);
|
|
1404
|
+
}
|
|
1405
|
+
/** Send one application message to an authority-owned logical session. */
|
|
1406
|
+
sendToSession(sessionId, data) {
|
|
1407
|
+
if (this.#state.kind === "failed" || this.#state.kind === "retired")
|
|
1408
|
+
return err(recoveryFailure("session is not connected to the authority"));
|
|
1409
|
+
const peerId = this.#peerForSession(sessionId);
|
|
1410
|
+
if (peerId === void 0) return err(recoveryFailure("logical session is not connected"));
|
|
1411
|
+
return this.#sendToPeer(peerId, data);
|
|
833
1412
|
}
|
|
834
1413
|
attachAuthority(authority) {
|
|
835
1414
|
this.#authority = authority;
|
|
@@ -837,30 +1416,183 @@ var NetSession = class {
|
|
|
837
1416
|
requestFullBaseline(peerId) {
|
|
838
1417
|
if (this.#peerIds.has(peerId)) this.#pendingFullPeers.add(peerId);
|
|
839
1418
|
}
|
|
1419
|
+
requestFullBaselineForSession(sessionId) {
|
|
1420
|
+
const peerId = this.#sessionPeers.get(sessionId);
|
|
1421
|
+
if (peerId !== void 0) this.requestFullBaseline(peerId);
|
|
1422
|
+
}
|
|
840
1423
|
attachReplica(coordinator, limits) {
|
|
841
1424
|
this.#replica = { coordinator, limits };
|
|
842
1425
|
}
|
|
1426
|
+
#ledgerBoundError() {
|
|
1427
|
+
return new NetError({
|
|
1428
|
+
code: "recovery-rejected",
|
|
1429
|
+
expected: "published packets within the configured finite ACK bound",
|
|
1430
|
+
hint: "wait for a cumulative ACK before publishing more packets",
|
|
1431
|
+
detail: { reason: "ACK ledger bound reached" }
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
#ensurePublicationCapacity(expectedEpoch) {
|
|
1435
|
+
if (expectedEpoch === this.#epoch && this.#ledger.size >= this.#policy.maxPendingPackets)
|
|
1436
|
+
return err(this.#ledgerBoundError());
|
|
1437
|
+
return ok(void 0);
|
|
1438
|
+
}
|
|
1439
|
+
#reservePublished(packet) {
|
|
1440
|
+
if (packet.epoch !== this.#epoch) {
|
|
1441
|
+
this.#ledger.clear();
|
|
1442
|
+
this.#acknowledgedSequence = 0;
|
|
1443
|
+
this.#epoch = packet.epoch;
|
|
1444
|
+
}
|
|
1445
|
+
if (this.#ledger.size >= this.#policy.maxPendingPackets && !this.#ledger.has(packet.sequence))
|
|
1446
|
+
return err(this.#ledgerBoundError());
|
|
1447
|
+
this.#sequence = packet.sequence;
|
|
1448
|
+
this.#ledger.set(packet.sequence, packet.bytes);
|
|
1449
|
+
return ok(void 0);
|
|
1450
|
+
}
|
|
1451
|
+
#sendPublished(packet, peerIds) {
|
|
1452
|
+
const reserved = this.#reservePublished(packet);
|
|
1453
|
+
if (!reserved.ok) return reserved;
|
|
1454
|
+
if (this.#endpoint === void 0) return err(recoveryFailure("session has no endpoint"));
|
|
1455
|
+
let delivered = false;
|
|
1456
|
+
for (const peerId of peerIds) {
|
|
1457
|
+
const sent = this.#endpoint.send(peerId, packet.bytes);
|
|
1458
|
+
if (!sent.ok) {
|
|
1459
|
+
if (sent.error.code === "connection-closed") {
|
|
1460
|
+
this.#forgetPeer(peerId);
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
this.#ledger.delete(packet.sequence);
|
|
1464
|
+
return err(sent.error);
|
|
1465
|
+
}
|
|
1466
|
+
delivered = true;
|
|
1467
|
+
}
|
|
1468
|
+
if (!delivered) this.#ledger.delete(packet.sequence);
|
|
1469
|
+
return ok(void 0);
|
|
1470
|
+
}
|
|
843
1471
|
publish() {
|
|
844
|
-
if (this.#authority === void 0
|
|
1472
|
+
if (this.#authority === void 0 || this.#endpoint === void 0 || this.#peerIds.size === 0)
|
|
1473
|
+
return ok(void 0);
|
|
845
1474
|
if (this.#pendingFullPeers.size > 0) {
|
|
1475
|
+
const capacity2 = this.#ensurePublicationCapacity(this.#authority.nextPublicationEpoch(true));
|
|
1476
|
+
if (!capacity2.ok) return capacity2;
|
|
846
1477
|
const published2 = this.#authority.publishFull();
|
|
847
1478
|
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
|
-
}
|
|
1479
|
+
const sent2 = this.#sendPublished(published2.value, [...this.#peerIds]);
|
|
1480
|
+
if (!sent2.ok) return err(sent2.error);
|
|
854
1481
|
this.#pendingFullPeers.clear();
|
|
1482
|
+
return ok(void 0);
|
|
855
1483
|
}
|
|
1484
|
+
const capacity = this.#ensurePublicationCapacity(this.#authority.nextPublicationEpoch());
|
|
1485
|
+
if (!capacity.ok) return capacity;
|
|
856
1486
|
const published = this.#authority.publish();
|
|
857
1487
|
if (!published.ok) return err(published.error);
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1488
|
+
const sent = this.#sendPublished(published.value, [...this.#peerIds]);
|
|
1489
|
+
if (!sent.ok) return err(sent.error);
|
|
1490
|
+
return ok(void 0);
|
|
1491
|
+
}
|
|
1492
|
+
dispose() {
|
|
1493
|
+
if (this.#disposed) return;
|
|
1494
|
+
this.#disposed = true;
|
|
1495
|
+
this.#clearRecoveryWork();
|
|
1496
|
+
this.#endpoint?.close();
|
|
1497
|
+
this.#endpoint = void 0;
|
|
1498
|
+
this.#replica?.coordinator.clear();
|
|
1499
|
+
this.#replica = void 0;
|
|
1500
|
+
this.#authority = void 0;
|
|
1501
|
+
this.#peerIds.clear();
|
|
1502
|
+
this.#sessionPeers.clear();
|
|
1503
|
+
this.#announcedPeers.clear();
|
|
1504
|
+
this.#sessionAnnounced = false;
|
|
1505
|
+
this.#pendingFullPeers.clear();
|
|
1506
|
+
this.#rawMessages = [];
|
|
1507
|
+
if (this.#state.kind !== "retired")
|
|
1508
|
+
this.#setState({ kind: "retired", sessionId: this.#sessionId, reason: "disposed" });
|
|
1509
|
+
}
|
|
1510
|
+
#queueRawMessage(peerId, data) {
|
|
1511
|
+
if (this.#rawMessages.length >= this.#maxRawMessages) return;
|
|
1512
|
+
this.#rawMessages.push({
|
|
1513
|
+
peerId,
|
|
1514
|
+
sessionId: this.#sessionForPeer(peerId),
|
|
1515
|
+
data: new Uint8Array(data)
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
#sessionForPeer(peerId) {
|
|
1519
|
+
for (const [sessionId2, mappedPeerId] of this.#sessionPeers)
|
|
1520
|
+
if (mappedPeerId === peerId) return sessionId2;
|
|
1521
|
+
if (this.#replica !== void 0) {
|
|
1522
|
+
this.#bindSession(this.#sessionId, peerId);
|
|
1523
|
+
return this.#sessionId;
|
|
861
1524
|
}
|
|
1525
|
+
const created = createSessionId(peerId);
|
|
1526
|
+
const sessionId = created.ok ? created.value : this.#sessionId;
|
|
1527
|
+
this.#bindSession(sessionId, peerId);
|
|
1528
|
+
return sessionId;
|
|
1529
|
+
}
|
|
1530
|
+
#bindSession(sessionId, peerId) {
|
|
1531
|
+
for (const [mappedSessionId, mappedPeerId] of this.#sessionPeers)
|
|
1532
|
+
if (mappedSessionId === sessionId || mappedPeerId === peerId)
|
|
1533
|
+
this.#sessionPeers.delete(mappedSessionId);
|
|
1534
|
+
this.#sessionPeers.set(sessionId, peerId);
|
|
1535
|
+
}
|
|
1536
|
+
#forgetPeer(peerId) {
|
|
1537
|
+
this.#peerIds.delete(peerId);
|
|
1538
|
+
for (const [sessionId, mappedPeerId] of this.#sessionPeers)
|
|
1539
|
+
if (mappedPeerId === peerId) this.#sessionPeers.delete(sessionId);
|
|
1540
|
+
this.#announcedPeers.delete(peerId);
|
|
1541
|
+
this.#pendingFullPeers.delete(peerId);
|
|
1542
|
+
}
|
|
1543
|
+
#peerForSession(sessionId) {
|
|
1544
|
+
const mapped = this.#sessionPeers.get(sessionId);
|
|
1545
|
+
if (mapped !== void 0 && this.#peerIds.has(mapped)) return mapped;
|
|
1546
|
+
if (this.#replica !== void 0 && this.#peerIds.size === 1) {
|
|
1547
|
+
const peerId = [...this.#peerIds][0];
|
|
1548
|
+
if (peerId !== void 0) {
|
|
1549
|
+
this.#bindSession(sessionId, peerId);
|
|
1550
|
+
return peerId;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
return void 0;
|
|
1554
|
+
}
|
|
1555
|
+
#announceSession(peerId) {
|
|
1556
|
+
if (this.#announcedPeers.has(peerId)) return ok(void 0);
|
|
1557
|
+
const packet = {
|
|
1558
|
+
version: 2,
|
|
1559
|
+
kind: this.#sessionAnnounced ? "session-resume" : "session-open",
|
|
1560
|
+
sessionId: this.#sessionId,
|
|
1561
|
+
epoch: this.#epoch,
|
|
1562
|
+
sequence: 0
|
|
1563
|
+
};
|
|
1564
|
+
const encoded = encodeReplicationPacket(packet, DEFAULT_REPLICATION_LIMITS);
|
|
1565
|
+
if (!encoded.ok) return err(encoded.error);
|
|
1566
|
+
const sent = this.#sendToPeer(peerId, encoded.value);
|
|
1567
|
+
if (!sent.ok) return sent;
|
|
1568
|
+
this.#announcedPeers.add(peerId);
|
|
1569
|
+
this.#sessionAnnounced = true;
|
|
862
1570
|
return ok(void 0);
|
|
863
1571
|
}
|
|
1572
|
+
#sendToPeer(peerId, data) {
|
|
1573
|
+
const result = this.#endpoint?.send(peerId, data);
|
|
1574
|
+
if (result === void 0) return err(recoveryFailure("session has no endpoint"));
|
|
1575
|
+
return result.ok ? ok(void 0) : err(result.error);
|
|
1576
|
+
}
|
|
1577
|
+
/** ACK accepted data at the session boundary; consumers should not reimplement this wire step. */
|
|
1578
|
+
#sendReplicationAck(peerId, packet) {
|
|
1579
|
+
const encoded = encodeReplicationPacket(
|
|
1580
|
+
{
|
|
1581
|
+
version: 2,
|
|
1582
|
+
kind: "ack",
|
|
1583
|
+
sessionId: packet.sessionId,
|
|
1584
|
+
epoch: packet.epoch,
|
|
1585
|
+
acknowledgedSequence: packet.sequence
|
|
1586
|
+
},
|
|
1587
|
+
DEFAULT_REPLICATION_LIMITS
|
|
1588
|
+
);
|
|
1589
|
+
if (!encoded.ok) {
|
|
1590
|
+
this.#setFailure(encoded.error);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const sent = this.#sendToPeer(peerId, encoded.value);
|
|
1594
|
+
if (!sent.ok) this.#lastError = sent.error;
|
|
1595
|
+
}
|
|
864
1596
|
};
|
|
865
1597
|
function netPlugin(config) {
|
|
866
1598
|
return {
|
|
@@ -869,12 +1601,23 @@ function netPlugin(config) {
|
|
|
869
1601
|
apply(ctx) {
|
|
870
1602
|
const world = ctx.world;
|
|
871
1603
|
const session = new NetSession({
|
|
872
|
-
endpoint: config.endpoint,
|
|
1604
|
+
...config.endpoint === void 0 ? {} : { endpoint: config.endpoint },
|
|
1605
|
+
...config.connector === void 0 ? {} : { connector: config.connector },
|
|
1606
|
+
...config.sessionId === void 0 ? {} : { sessionId: config.sessionId },
|
|
1607
|
+
...config.recovery === void 0 ? {} : { recovery: config.recovery },
|
|
1608
|
+
...config.clock === void 0 ? {} : { clock: config.clock },
|
|
873
1609
|
maxRawMessages: config.maxRawMessages ?? 256
|
|
874
1610
|
});
|
|
875
1611
|
ctx.effect(() => {
|
|
876
1612
|
world.insertResource("net-session", session);
|
|
877
|
-
|
|
1613
|
+
if (config.connector !== void 0 && config.endpoint === void 0) {
|
|
1614
|
+
session.recover();
|
|
1615
|
+
session.advanceRecovery();
|
|
1616
|
+
}
|
|
1617
|
+
return () => {
|
|
1618
|
+
session.dispose();
|
|
1619
|
+
world.removeResource("net-session");
|
|
1620
|
+
};
|
|
878
1621
|
}, "net/session-resource");
|
|
879
1622
|
ctx.effect(() => {
|
|
880
1623
|
world.addSystem(Update, {
|
|
@@ -898,6 +1641,6 @@ function netPlugin(config) {
|
|
|
898
1641
|
};
|
|
899
1642
|
}
|
|
900
1643
|
|
|
901
|
-
export { AuthorityCoordinator, ENDPOINT_ERROR_HINTS, ENDPOINT_EXPECTED, EndpointError, NetError, NetSession, ReplicaCoordinator,
|
|
1644
|
+
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
1645
|
//# sourceMappingURL=index.mjs.map
|
|
903
1646
|
//# sourceMappingURL=index.mjs.map
|