@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.
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 +860 -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 +88 -28
  39. package/src/session/net-session.ts +612 -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,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(batch) {
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 (batch.fingerprint !== this.#profile.fingerprint)
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
- if (batch.tick <= this.#lastTick)
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: batch.tick, lastTick: this.#lastTick }
796
+ detail: { receivedTick: packet.tick, lastTick: this.#lastTick }
650
797
  });
651
798
  const batchIds = /* @__PURE__ */ new Set();
652
- for (const record of batch.entities) {
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 batch.entities) {
663
- if (record.kind === "despawn" && !this.#entities.has(record.id))
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 || !this.#entities.has(reference) && !batchIds.has(reference)))
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(batch) {
706
- const failure = this.validate(batch);
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
- for (const record of batch.entities)
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 batch.entities)
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 batch.entities)
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.#lastTick = batch.tick;
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 applyReplicaBatch(replica, batch) {
776
- return replica.apply(batch);
935
+ function applyReplicationPacket(replica, packet) {
936
+ return replica.apply(packet);
777
937
  }
778
- function decodeAndApplyReplicaBatch(replica, bytes, limits) {
779
- const decoded = decodeReplicationBatch(bytes, limits);
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
- for (const event of this.#endpoint.poll()) {
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.#peerIds.delete(event.peerId);
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
- 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 });
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
- const result = this.#endpoint.send(peerId, data);
832
- return result.ok ? ok(void 0) : err(result.error);
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) return ok(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
- 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
- }
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
- for (const peerId of this.#peerIds) {
859
- const sent = this.#endpoint.send(peerId, published.value.bytes);
860
- if (!sent.ok) return err(sent.error);
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
- return () => world.removeResource("net-session");
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, applyReplicaBatch, createAuthorityCoordinator, createMemoryEndpointPair, createMemoryEndpointPairWithController, createReplicaCoordinator, decodeAndApplyReplicaBatch, defineReplication, isEndpointError, netPlugin, validateHandshake };
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