@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
@@ -0,0 +1,204 @@
1
+ import { err, ok, type Result } from '@forgeax/engine-types';
2
+ import type { NetEndpointConnector } from '../endpoint/endpoint';
3
+ import type { EndpointError } from '../endpoint/errors';
4
+ import { NetError, type NetErrorCode } from '../replication/errors';
5
+
6
+ declare const sessionIdBrand: unique symbol;
7
+
8
+ /** Authority-issued application identity; it is distinct from transport PeerId. */
9
+ export type SessionId = number & { readonly [sessionIdBrand]: true };
10
+
11
+ export type NetSessionStateKind =
12
+ | 'connecting'
13
+ | 'resyncing'
14
+ | 'active'
15
+ | 'recovering'
16
+ | 'failed'
17
+ | 'retired';
18
+
19
+ export type NetSessionFailure = NetError | EndpointError;
20
+
21
+ /** Public lifecycle state owned by one logical session. */
22
+ export type NetSessionState =
23
+ | { readonly kind: 'connecting'; readonly sessionId: SessionId }
24
+ | { readonly kind: 'resyncing'; readonly sessionId: SessionId; readonly epoch: number }
25
+ | {
26
+ readonly kind: 'active';
27
+ readonly sessionId: SessionId;
28
+ readonly epoch: number;
29
+ readonly sequence: number;
30
+ }
31
+ | {
32
+ readonly kind: 'recovering';
33
+ readonly sessionId: SessionId;
34
+ readonly epoch: number;
35
+ readonly attempt: number;
36
+ }
37
+ | {
38
+ readonly kind: 'failed';
39
+ readonly sessionId: SessionId;
40
+ readonly error: NetSessionFailure;
41
+ }
42
+ | {
43
+ readonly kind: 'retired';
44
+ readonly sessionId: SessionId;
45
+ readonly reason: 'disposed' | 'terminal-failure';
46
+ };
47
+
48
+ export interface NetRecoveryPolicy {
49
+ readonly maxSessions: number;
50
+ readonly maxPendingPackets: number;
51
+ readonly ackTimeoutMs: number;
52
+ readonly maxPacketRetries: number;
53
+ readonly maxReconnectAttempts: number;
54
+ readonly reconnectDeadlineMs: number;
55
+ readonly reconnectDelaysMs: readonly number[];
56
+ }
57
+
58
+ /** Finite, deterministic recovery bounds shared by every transport adapter. */
59
+ export const DEFAULT_NET_RECOVERY_POLICY: NetRecoveryPolicy = Object.freeze({
60
+ maxSessions: 64,
61
+ maxPendingPackets: 32,
62
+ ackTimeoutMs: 250,
63
+ maxPacketRetries: 3,
64
+ maxReconnectAttempts: 5,
65
+ reconnectDeadlineMs: 10_000,
66
+ reconnectDelaysMs: Object.freeze([0, 100, 200, 400, 800]),
67
+ });
68
+
69
+ function policyError(field: string, reason: string): NetError {
70
+ return new NetError({
71
+ code: 'recovery-policy-invalid',
72
+ expected: 'finite positive recovery policy bounds',
73
+ hint: 'provide positive safe integers and a finite non-negative delay sequence',
74
+ detail: { field, reason },
75
+ });
76
+ }
77
+
78
+ function isPositiveSafeInteger(value: unknown): value is number {
79
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
80
+ }
81
+
82
+ /** Validate a complete policy without mutating it. */
83
+ export function validateNetRecoveryPolicy(policy: NetRecoveryPolicy): Result<void, NetError> {
84
+ const positiveFields: readonly (keyof Omit<NetRecoveryPolicy, 'reconnectDelaysMs'>)[] = [
85
+ 'maxSessions',
86
+ 'maxPendingPackets',
87
+ 'ackTimeoutMs',
88
+ 'maxPacketRetries',
89
+ 'maxReconnectAttempts',
90
+ 'reconnectDeadlineMs',
91
+ ];
92
+ for (const field of positiveFields) {
93
+ if (!isPositiveSafeInteger(policy[field]))
94
+ return err(policyError(field, 'value must be a positive safe integer'));
95
+ }
96
+ if (
97
+ !Array.isArray(policy.reconnectDelaysMs) ||
98
+ policy.reconnectDelaysMs.length === 0 ||
99
+ policy.reconnectDelaysMs.some((delay) => !Number.isSafeInteger(delay) || delay < 0)
100
+ )
101
+ return err(
102
+ policyError('reconnectDelaysMs', 'values must be a non-empty finite delay sequence'),
103
+ );
104
+ return ok(undefined);
105
+ }
106
+
107
+ /** Merge caller overrides with the bounded defaults and validate the result. */
108
+ export function resolveNetRecoveryPolicy(
109
+ overrides: Partial<NetRecoveryPolicy> = {},
110
+ ): Result<NetRecoveryPolicy, NetError> {
111
+ const policy: NetRecoveryPolicy = {
112
+ ...DEFAULT_NET_RECOVERY_POLICY,
113
+ ...overrides,
114
+ reconnectDelaysMs:
115
+ overrides.reconnectDelaysMs === undefined
116
+ ? DEFAULT_NET_RECOVERY_POLICY.reconnectDelaysMs
117
+ : [...overrides.reconnectDelaysMs],
118
+ };
119
+ const valid = validateNetRecoveryPolicy(policy);
120
+ return valid.ok ? ok(Object.freeze(policy)) : err(valid.error);
121
+ }
122
+
123
+ /** Create a positive application identity without allowing plain numbers to cross the seam. */
124
+ export function createSessionId(value: number): Result<SessionId, NetError> {
125
+ if (!isPositiveSafeInteger(value))
126
+ return err(
127
+ new NetError({
128
+ code: 'recovery-policy-invalid',
129
+ expected: 'a positive safe integer SessionId',
130
+ hint: 'use the authority-issued application session identity',
131
+ detail: { field: 'sessionId', reason: 'SessionId must be a positive safe integer' },
132
+ }),
133
+ );
134
+ return ok(value as SessionId);
135
+ }
136
+
137
+ /** Observable bounded accounting for one logical recovery session. */
138
+ export interface NetRecoverySnapshot {
139
+ readonly sessionId: SessionId;
140
+ readonly state: NetSessionState;
141
+ readonly pendingPackets: number;
142
+ readonly maxPendingPackets: number;
143
+ readonly acknowledgedSequence: number;
144
+ readonly reconnectAttempts: number;
145
+ readonly epoch: number;
146
+ readonly sequence: number;
147
+ readonly ownedResources: {
148
+ readonly pendingConnects: number;
149
+ readonly timers: number;
150
+ readonly ledgers: number;
151
+ readonly callbacks: number;
152
+ };
153
+ readonly lastError?: NetSessionFailure;
154
+ }
155
+
156
+ /** Stable result kinds for repeated recovery requests. */
157
+ export type NetRecoveryOutcome =
158
+ | { readonly kind: 'started'; readonly sessionId: SessionId }
159
+ | { readonly kind: 'already-recovering'; readonly sessionId: SessionId }
160
+ | { readonly kind: 'already-active'; readonly sessionId: SessionId }
161
+ | { readonly kind: 'retired'; readonly sessionId: SessionId };
162
+
163
+ const LEGAL_TRANSITIONS: Readonly<Record<NetSessionStateKind, readonly NetSessionStateKind[]>> = {
164
+ connecting: ['recovering', 'resyncing', 'failed', 'retired'],
165
+ resyncing: ['active', 'recovering', 'failed', 'retired'],
166
+ active: ['active', 'recovering', 'failed', 'retired'],
167
+ recovering: ['recovering', 'resyncing', 'failed', 'retired'],
168
+ failed: ['retired'],
169
+ retired: ['retired'],
170
+ };
171
+
172
+ export function isLegalNetSessionTransition(
173
+ from: NetSessionStateKind,
174
+ to: NetSessionStateKind,
175
+ ): boolean {
176
+ return LEGAL_TRANSITIONS[from].includes(to);
177
+ }
178
+
179
+ /** Validate an immutable state replacement before a session publishes it. */
180
+ export function transitionNetSessionState(
181
+ from: NetSessionState,
182
+ to: NetSessionState,
183
+ ): Result<NetSessionState, NetError> {
184
+ if (from.sessionId !== to.sessionId || !isLegalNetSessionTransition(from.kind, to.kind))
185
+ return err(
186
+ new NetError({
187
+ code: 'session-illegal-transition',
188
+ expected: 'a legal transition for the same SessionId',
189
+ hint: 'wait for the current session state or retire the session before replacing it',
190
+ detail: { from: from.kind, to: to.kind },
191
+ }),
192
+ );
193
+ return ok(to);
194
+ }
195
+
196
+ export type { NetEndpointConnector };
197
+
198
+ export const RECOVERY_ERROR_CODES: readonly NetErrorCode[] = [
199
+ 'protocol-unsupported-version',
200
+ 'session-illegal-transition',
201
+ 'recovery-policy-invalid',
202
+ 'recovery-rejected',
203
+ 'recovery-exhausted',
204
+ ];
@@ -3,11 +3,16 @@
3
3
 
4
4
  import { FixedUpdate, Update } from '@forgeax/engine-ecs';
5
5
  import type { Plugin } from '@forgeax/engine-plugin';
6
- import type { NetEndpoint } from '../endpoint/endpoint';
7
- import { NetSession } from './net-session';
6
+ import type { NetEndpoint, NetEndpointConnector } from '../endpoint/endpoint';
7
+ import { NetSession, type NetSessionClock } from './net-session';
8
+ import type { NetRecoveryPolicy } from './recovery';
8
9
 
9
10
  export interface NetPluginConfig {
10
- readonly endpoint: NetEndpoint;
11
+ readonly endpoint?: NetEndpoint;
12
+ readonly connector?: NetEndpointConnector;
13
+ readonly sessionId?: number;
14
+ readonly recovery?: Partial<NetRecoveryPolicy>;
15
+ readonly clock?: NetSessionClock;
11
16
  readonly maxRawMessages?: number;
12
17
  }
13
18
 
@@ -18,12 +23,23 @@ export function netPlugin(config: NetPluginConfig): Plugin {
18
23
  apply(ctx) {
19
24
  const world = ctx.world;
20
25
  const session = new NetSession({
21
- endpoint: config.endpoint,
26
+ ...(config.endpoint === undefined ? {} : { endpoint: config.endpoint }),
27
+ ...(config.connector === undefined ? {} : { connector: config.connector }),
28
+ ...(config.sessionId === undefined ? {} : { sessionId: config.sessionId }),
29
+ ...(config.recovery === undefined ? {} : { recovery: config.recovery }),
30
+ ...(config.clock === undefined ? {} : { clock: config.clock }),
22
31
  maxRawMessages: config.maxRawMessages ?? 256,
23
32
  });
24
33
  ctx.effect(() => {
25
34
  world.insertResource('net-session', session);
26
- return () => world.removeResource('net-session');
35
+ if (config.connector !== undefined && config.endpoint === undefined) {
36
+ session.recover();
37
+ session.advanceRecovery();
38
+ }
39
+ return () => {
40
+ session.dispose();
41
+ world.removeResource('net-session');
42
+ };
27
43
  }, 'net/session-resource');
28
44
  ctx.effect(() => {
29
45
  world