@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.
Files changed (41) hide show
  1. package/README.md +156 -105
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/endpoint/endpoint.d.ts +7 -0
  4. package/dist/endpoint/endpoint.d.ts.map +1 -1
  5. package/dist/endpoint/memory.d.ts +3 -1
  6. package/dist/endpoint/memory.d.ts.map +1 -1
  7. package/dist/index.d.ts +11 -7
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.mjs +862 -117
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/replication/authority.d.ts +8 -6
  12. package/dist/replication/authority.d.ts.map +1 -1
  13. package/dist/replication/codec.d.ts +4 -22
  14. package/dist/replication/codec.d.ts.map +1 -1
  15. package/dist/replication/constants.d.ts +4 -1
  16. package/dist/replication/constants.d.ts.map +1 -1
  17. package/dist/replication/errors.d.ts +24 -4
  18. package/dist/replication/errors.d.ts.map +1 -1
  19. package/dist/replication/protocol.d.ts +63 -0
  20. package/dist/replication/protocol.d.ts.map +1 -0
  21. package/dist/replication/replica.d.ts +9 -6
  22. package/dist/replication/replica.d.ts.map +1 -1
  23. package/dist/session/net-session.d.ts +40 -6
  24. package/dist/session/net-session.d.ts.map +1 -1
  25. package/dist/session/recovery.d.ts +93 -0
  26. package/dist/session/recovery.d.ts.map +1 -0
  27. package/dist/session/session-plugin.d.ts +8 -2
  28. package/dist/session/session-plugin.d.ts.map +1 -1
  29. package/package.json +4 -4
  30. package/src/endpoint/endpoint.ts +8 -0
  31. package/src/endpoint/memory.ts +23 -1
  32. package/src/index.ts +56 -12
  33. package/src/replication/authority.ts +55 -23
  34. package/src/replication/codec.ts +167 -101
  35. package/src/replication/constants.ts +5 -1
  36. package/src/replication/errors.ts +22 -4
  37. package/src/replication/protocol.ts +86 -0
  38. package/src/replication/replica.ts +89 -28
  39. package/src/session/net-session.ts +617 -38
  40. package/src/session/recovery.ts +204 -0
  41. 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(err8) {
68
- return err8 instanceof EndpointErrorClass;
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 = 1;
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 || "values" 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 validateLimits(batch, bytes, limits) {
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 (batch.entities.length > limits.maxEntities)
302
- return limitError("maxEntities", batch.entities.length, limits.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 batch.entities) {
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(new TextDecoder().decode(bytes));
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 { reason: "batch must be an object" };
351
- const batch = revived.value;
352
- if (!Array.isArray(batch.entities) || typeof batch.fingerprint !== "string" || !Number.isSafeInteger(batch.tick) || !Number.isSafeInteger(batch.version) || typeof batch.full !== "boolean")
353
- return { reason: "batch envelope has an invalid field type" };
354
- for (const [entityIndex, entity] of batch.entities.entries()) {
355
- if (entity === null || typeof entity !== "object")
356
- return { reason: `entity record ${entityIndex} must be an object` };
357
- const record = entity;
358
- if (!Number.isSafeInteger(record.id) || !isReplicationEntityKind(record.kind) || !Array.isArray(record.components))
359
- return { reason: `entity record ${entityIndex} has an invalid field type` };
360
- for (const [componentIndex, component] of record.components.entries()) {
361
- if (component === null || typeof component !== "object")
362
- return { reason: `component record ${entityIndex}:${componentIndex} must be an object` };
363
- const entry = component;
364
- if (typeof entry.name !== "string" || entry.name.length === 0 || entry.operation !== void 0 && entry.operation !== "replace" && entry.operation !== "remove" || entry.data === null || typeof entry.data !== "object" || Array.isArray(entry.data) || entry.operation === "remove" && Object.keys(entry.data).length !== 0)
365
- return {
366
- reason: `component record ${entityIndex}:${componentIndex} has an invalid field type`
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 { batch };
453
+ return { packet };
371
454
  } catch {
372
- return { reason: "payload is not valid JSON" };
455
+ return { error: invalid("payload is not valid JSON") };
373
456
  }
374
457
  }
375
- function encodeReplicationBatch(batch, limits) {
376
- const bytes = new TextEncoder().encode(JSON.stringify(canonicalize(batch)));
377
- const failure = validateLimits(batch, bytes, limits);
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 decodeReplicationBatch(bytes, limits) {
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 ("reason" in parsed || parsed.batch.version !== REPLICATION_PROTOCOL_VERSION)
385
- return err(
386
- new NetError({
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
- constructor(world, profile) {
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 batch = {
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 = encodeReplicationBatch(
554
- batch,
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 = batch.tick;
564
- return ok({ ...batch, bytes: encoded.value });
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, endpoint) {
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(batch) {
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 (batch.fingerprint !== this.#profile.fingerprint)
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
- if (batch.tick <= this.#lastTick)
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: batch.tick, lastTick: this.#lastTick }
797
+ detail: { receivedTick: packet.tick, lastTick: this.#lastTick }
650
798
  });
651
799
  const batchIds = /* @__PURE__ */ new Set();
652
- for (const record of batch.entities) {
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 batch.entities) {
663
- if (record.kind === "despawn" && !this.#entities.has(record.id))
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 || !this.#entities.has(reference) && !batchIds.has(reference)))
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(batch) {
706
- const failure = this.validate(batch);
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
- for (const record of batch.entities)
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 batch.entities)
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 batch.entities)
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.#lastTick = batch.tick;
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 applyReplicaBatch(replica, batch) {
776
- return replica.apply(batch);
936
+ function applyReplicationPacket(replica, packet) {
937
+ return replica.apply(packet);
777
938
  }
778
- function decodeAndApplyReplicaBatch(replica, bytes, limits) {
779
- const decoded = decodeReplicationBatch(bytes, limits);
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
- for (const event of this.#endpoint.poll()) {
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.#peerIds.delete(event.peerId);
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
- const result = decodeAndApplyReplicaBatch(
811
- this.#replica.coordinator,
812
- event.data,
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
- const result = this.#endpoint.send(peerId, data);
832
- return result.ok ? ok(void 0) : err(result.error);
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) return ok(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
- for (const peerId of this.#pendingFullPeers) {
849
- if (this.#peerIds.has(peerId)) {
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
- for (const peerId of this.#peerIds) {
859
- const sent = this.#endpoint.send(peerId, published.value.bytes);
860
- if (!sent.ok) return err(sent.error);
1490
+ const sent = this.#sendPublished(published.value, [...this.#peerIds]);
1491
+ if (!sent.ok) return err(sent.error);
1492
+ return ok(void 0);
1493
+ }
1494
+ dispose() {
1495
+ if (this.#disposed) return;
1496
+ this.#disposed = true;
1497
+ this.#clearRecoveryWork();
1498
+ this.#endpoint?.close();
1499
+ this.#endpoint = void 0;
1500
+ this.#replica?.coordinator.clear();
1501
+ this.#replica = void 0;
1502
+ this.#authority = void 0;
1503
+ this.#peerIds.clear();
1504
+ this.#sessionPeers.clear();
1505
+ this.#announcedPeers.clear();
1506
+ this.#sessionAnnounced = false;
1507
+ this.#pendingFullPeers.clear();
1508
+ this.#rawMessages = [];
1509
+ if (this.#state.kind !== "retired")
1510
+ this.#setState({ kind: "retired", sessionId: this.#sessionId, reason: "disposed" });
1511
+ }
1512
+ #queueRawMessage(peerId, data) {
1513
+ if (this.#rawMessages.length >= this.#maxRawMessages) return;
1514
+ this.#rawMessages.push({
1515
+ peerId,
1516
+ sessionId: this.#sessionForPeer(peerId),
1517
+ data: new Uint8Array(data)
1518
+ });
1519
+ }
1520
+ #sessionForPeer(peerId) {
1521
+ for (const [sessionId2, mappedPeerId] of this.#sessionPeers)
1522
+ if (mappedPeerId === peerId) return sessionId2;
1523
+ if (this.#replica !== void 0) {
1524
+ this.#bindSession(this.#sessionId, peerId);
1525
+ return this.#sessionId;
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
- return () => world.removeResource("net-session");
1615
+ if (config.connector !== void 0 && config.endpoint === void 0) {
1616
+ session.recover();
1617
+ session.advanceRecovery();
1618
+ }
1619
+ return () => {
1620
+ session.dispose();
1621
+ world.removeResource("net-session");
1622
+ };
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, applyReplicaBatch, createAuthorityCoordinator, createMemoryEndpointPair, createMemoryEndpointPairWithController, createReplicaCoordinator, decodeAndApplyReplicaBatch, defineReplication, isEndpointError, netPlugin, validateHandshake };
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