@forgeax/engine-net 0.0.0-dev.8d955ade1c79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +196 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/endpoint/endpoint.d.ts +43 -0
- package/dist/endpoint/endpoint.d.ts.map +1 -0
- package/dist/endpoint/errors.d.ts +82 -0
- package/dist/endpoint/errors.d.ts.map +1 -0
- package/dist/endpoint/memory.d.ts +15 -0
- package/dist/endpoint/memory.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1648 -0
- package/dist/index.mjs.map +1 -0
- package/dist/replication/authority.d.ts +19 -0
- package/dist/replication/authority.d.ts.map +1 -0
- package/dist/replication/codec.d.ts +8 -0
- package/dist/replication/codec.d.ts.map +1 -0
- package/dist/replication/constants.d.ts +5 -0
- package/dist/replication/constants.d.ts.map +1 -0
- package/dist/replication/errors.d.ts +86 -0
- package/dist/replication/errors.d.ts.map +1 -0
- package/dist/replication/handshake.d.ts +5 -0
- package/dist/replication/handshake.d.ts.map +1 -0
- package/dist/replication/profile.d.ts +31 -0
- package/dist/replication/profile.d.ts.map +1 -0
- package/dist/replication/protocol.d.ts +63 -0
- package/dist/replication/protocol.d.ts.map +1 -0
- package/dist/replication/replica.d.ts +30 -0
- package/dist/replication/replica.d.ts.map +1 -0
- package/dist/session/net-session.d.ts +66 -0
- package/dist/session/net-session.d.ts.map +1 -0
- package/dist/session/recovery.d.ts +93 -0
- package/dist/session/recovery.d.ts.map +1 -0
- package/dist/session/session-plugin.d.ts +14 -0
- package/dist/session/session-plugin.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/endpoint/endpoint.ts +58 -0
- package/src/endpoint/errors.ts +164 -0
- package/src/endpoint/memory.ts +194 -0
- package/src/index.ts +90 -0
- package/src/replication/authority.ts +177 -0
- package/src/replication/codec.ts +323 -0
- package/src/replication/constants.ts +5 -0
- package/src/replication/errors.ts +78 -0
- package/src/replication/handshake.ts +18 -0
- package/src/replication/profile.ts +111 -0
- package/src/replication/protocol.ts +86 -0
- package/src/replication/replica.ts +301 -0
- package/src/session/net-session.ts +697 -0
- package/src/session/recovery.ts +204 -0
- package/src/session/session-plugin.ts +68 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
import { REPLICATION_PROTOCOL_PREFIX, REPLICATION_PROTOCOL_VERSION } from './constants';
|
|
3
|
+
import { NetError } from './errors';
|
|
4
|
+
import type { ReplicationLimits } from './profile';
|
|
5
|
+
import type { ReplicationDataPacket, ReplicationEntityRecord, ReplicationPacket } from './protocol';
|
|
6
|
+
|
|
7
|
+
export type { ReplicationComponentRecord, ReplicationEntityRecord } from './protocol';
|
|
8
|
+
|
|
9
|
+
type PortableTypedArray =
|
|
10
|
+
| Float32Array
|
|
11
|
+
| Float64Array
|
|
12
|
+
| Int8Array
|
|
13
|
+
| Int16Array
|
|
14
|
+
| Int32Array
|
|
15
|
+
| Uint8Array
|
|
16
|
+
| Uint8ClampedArray
|
|
17
|
+
| Uint16Array
|
|
18
|
+
| Uint32Array;
|
|
19
|
+
|
|
20
|
+
const TYPED_ARRAYS = {
|
|
21
|
+
Float32Array,
|
|
22
|
+
Float64Array,
|
|
23
|
+
Int8Array,
|
|
24
|
+
Int16Array,
|
|
25
|
+
Int32Array,
|
|
26
|
+
Uint8Array,
|
|
27
|
+
Uint8ClampedArray,
|
|
28
|
+
Uint16Array,
|
|
29
|
+
Uint32Array,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
type TypedArrayName = keyof typeof TYPED_ARRAYS;
|
|
33
|
+
|
|
34
|
+
const PACKET_KINDS = [
|
|
35
|
+
'session-open',
|
|
36
|
+
'session-resume',
|
|
37
|
+
'baseline',
|
|
38
|
+
'delta',
|
|
39
|
+
'ack',
|
|
40
|
+
'rejection',
|
|
41
|
+
] as const satisfies readonly ReplicationPacket['kind'][];
|
|
42
|
+
|
|
43
|
+
const REPLICATION_ENTITY_KINDS = [
|
|
44
|
+
'upsert',
|
|
45
|
+
'despawn',
|
|
46
|
+
] as const satisfies readonly ReplicationEntityRecord['kind'][];
|
|
47
|
+
|
|
48
|
+
function isPacketKind(value: unknown): value is ReplicationPacket['kind'] {
|
|
49
|
+
return PACKET_KINDS.some((kind) => kind === value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isReplicationEntityKind(value: unknown): value is ReplicationEntityRecord['kind'] {
|
|
53
|
+
return REPLICATION_ENTITY_KINDS.some((kind) => kind === value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isSafeNonNegativeInteger(value: unknown): value is number {
|
|
57
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isSessionId(value: unknown): boolean {
|
|
61
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function typedArrayName(value: unknown): TypedArrayName | undefined {
|
|
65
|
+
for (const [name, typedArrayConstructor] of Object.entries(TYPED_ARRAYS) as [
|
|
66
|
+
TypedArrayName,
|
|
67
|
+
(typeof TYPED_ARRAYS)[TypedArrayName],
|
|
68
|
+
][]) {
|
|
69
|
+
if (value instanceof typedArrayConstructor) return name;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function canonicalize(value: unknown): unknown {
|
|
75
|
+
const name = typedArrayName(value);
|
|
76
|
+
if (name !== undefined)
|
|
77
|
+
return { $typedArray: name, values: Array.from(value as PortableTypedArray) };
|
|
78
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
79
|
+
if (value !== null && typeof value === 'object')
|
|
80
|
+
return Object.fromEntries(
|
|
81
|
+
Object.keys(value)
|
|
82
|
+
.sort()
|
|
83
|
+
.map((key) => [key, canonicalize((value as Record<string, unknown>)[key])]),
|
|
84
|
+
);
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function reviveTypedArrays(
|
|
89
|
+
value: unknown,
|
|
90
|
+
): { readonly value: unknown } | { readonly reason: string } {
|
|
91
|
+
if (Array.isArray(value)) {
|
|
92
|
+
const values: unknown[] = [];
|
|
93
|
+
for (const item of value) {
|
|
94
|
+
const revived = reviveTypedArrays(item);
|
|
95
|
+
if ('reason' in revived) return revived;
|
|
96
|
+
values.push(revived.value);
|
|
97
|
+
}
|
|
98
|
+
return { value: values };
|
|
99
|
+
}
|
|
100
|
+
if (value === null || typeof value !== 'object') return { value };
|
|
101
|
+
const record = value as Record<string, unknown>;
|
|
102
|
+
if ('$typedArray' in record) {
|
|
103
|
+
if (
|
|
104
|
+
Object.keys(record).length !== 2 ||
|
|
105
|
+
typeof record.$typedArray !== 'string' ||
|
|
106
|
+
!Array.isArray(record.values)
|
|
107
|
+
)
|
|
108
|
+
return { reason: 'typed-array tag must contain only an allowlisted name and values array' };
|
|
109
|
+
const typedArrayConstructor = TYPED_ARRAYS[record.$typedArray as TypedArrayName];
|
|
110
|
+
if (
|
|
111
|
+
typedArrayConstructor === undefined ||
|
|
112
|
+
record.values.some((item) => typeof item !== 'number')
|
|
113
|
+
)
|
|
114
|
+
return { reason: 'typed-array tag contains an unsupported type or non-numeric value' };
|
|
115
|
+
return { value: new typedArrayConstructor(record.values) };
|
|
116
|
+
}
|
|
117
|
+
const revived: Record<string, unknown> = {};
|
|
118
|
+
for (const [key, item] of Object.entries(record)) {
|
|
119
|
+
const nested = reviveTypedArrays(item);
|
|
120
|
+
if ('reason' in nested) return nested;
|
|
121
|
+
revived[key] = nested.value;
|
|
122
|
+
}
|
|
123
|
+
return { value: revived };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function limitError(limit: string, actual: number, maximum: number): NetError {
|
|
127
|
+
return new NetError({
|
|
128
|
+
code: 'decode-limit-exceeded',
|
|
129
|
+
expected: `${limit} must not exceed ${maximum}`,
|
|
130
|
+
hint: 'reduce the replicated payload or configure matching declared limits',
|
|
131
|
+
detail: { limit, actual, maximum },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function invalid(reason: string): NetError {
|
|
136
|
+
return new NetError({
|
|
137
|
+
code: 'decode-invalid-payload',
|
|
138
|
+
expected: `a version ${REPLICATION_PROTOCOL_VERSION} ${REPLICATION_PROTOCOL_PREFIX} packet`,
|
|
139
|
+
hint: 'send bytes produced by the protocol-v2 replication codec',
|
|
140
|
+
detail: { reason },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validateEntities(entities: readonly ReplicationEntityRecord[]): string | undefined {
|
|
145
|
+
const ids = new Set<number>();
|
|
146
|
+
for (const [entityIndex, entity] of entities.entries()) {
|
|
147
|
+
if (
|
|
148
|
+
entity === null ||
|
|
149
|
+
typeof entity !== 'object' ||
|
|
150
|
+
!isSafeNonNegativeInteger(entity.id) ||
|
|
151
|
+
!isReplicationEntityKind(entity.kind) ||
|
|
152
|
+
!Array.isArray(entity.components) ||
|
|
153
|
+
ids.has(entity.id)
|
|
154
|
+
)
|
|
155
|
+
return `entity record ${entityIndex} has an invalid or duplicate identity`;
|
|
156
|
+
ids.add(entity.id);
|
|
157
|
+
for (const [componentIndex, component] of entity.components.entries()) {
|
|
158
|
+
if (
|
|
159
|
+
component === null ||
|
|
160
|
+
typeof component !== 'object' ||
|
|
161
|
+
typeof component.name !== 'string' ||
|
|
162
|
+
component.name.length === 0 ||
|
|
163
|
+
(component.operation !== undefined &&
|
|
164
|
+
component.operation !== 'replace' &&
|
|
165
|
+
component.operation !== 'remove') ||
|
|
166
|
+
component.data === null ||
|
|
167
|
+
typeof component.data !== 'object' ||
|
|
168
|
+
Array.isArray(component.data) ||
|
|
169
|
+
(component.operation === 'remove' && Object.keys(component.data).length !== 0)
|
|
170
|
+
)
|
|
171
|
+
return `component record ${entityIndex}:${componentIndex} has invalid fields`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function validatePacket(packet: ReplicationPacket): string | undefined {
|
|
178
|
+
if (packet.version !== REPLICATION_PROTOCOL_VERSION)
|
|
179
|
+
return 'packet protocol version is unsupported';
|
|
180
|
+
if (!isPacketKind(packet.kind)) return 'packet kind is unsupported';
|
|
181
|
+
if (!isSessionId(packet.sessionId)) return 'sessionId must be a positive safe integer';
|
|
182
|
+
if (!isSafeNonNegativeInteger(packet.epoch)) return 'epoch must be a non-negative safe integer';
|
|
183
|
+
if (packet.kind === 'session-open' || packet.kind === 'session-resume')
|
|
184
|
+
return packet.sequence === 0 ? undefined : 'session control sequence must be zero';
|
|
185
|
+
if (packet.kind === 'ack')
|
|
186
|
+
return isSafeNonNegativeInteger(packet.acknowledgedSequence)
|
|
187
|
+
? undefined
|
|
188
|
+
: 'acknowledgedSequence must be a non-negative safe integer';
|
|
189
|
+
if (!isSafeNonNegativeInteger(packet.sequence) || packet.sequence === 0)
|
|
190
|
+
return 'sequence must be a positive safe integer';
|
|
191
|
+
if (packet.kind === 'baseline' && packet.sequence !== 1) return 'baseline sequence must be one';
|
|
192
|
+
if (packet.kind === 'rejection') {
|
|
193
|
+
if (!isPacketKind(packet.rejectedKind) || typeof packet.reason !== 'string')
|
|
194
|
+
return 'rejection details are invalid';
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
if (packet.kind !== 'baseline' && packet.kind !== 'delta')
|
|
198
|
+
return 'packet kind does not carry a data payload';
|
|
199
|
+
if (typeof packet.tick !== 'number' || !Number.isSafeInteger(packet.tick))
|
|
200
|
+
return 'tick must be a safe integer';
|
|
201
|
+
if (typeof packet.fingerprint !== 'string') return 'fingerprint must be a string';
|
|
202
|
+
return validateEntities(packet.entities);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function validateLimits(
|
|
206
|
+
packet: ReplicationDataPacket,
|
|
207
|
+
bytes: Uint8Array | undefined,
|
|
208
|
+
limits: ReplicationLimits,
|
|
209
|
+
): NetError | null {
|
|
210
|
+
if (bytes !== undefined && bytes.byteLength > limits.maxMessageBytes)
|
|
211
|
+
return limitError('maxMessageBytes', bytes.byteLength, limits.maxMessageBytes);
|
|
212
|
+
if (packet.entities.length > limits.maxEntities)
|
|
213
|
+
return limitError('maxEntities', packet.entities.length, limits.maxEntities);
|
|
214
|
+
let operations = 0;
|
|
215
|
+
const visit = (value: unknown): NetError | null => {
|
|
216
|
+
if (
|
|
217
|
+
typeof value === 'string' &&
|
|
218
|
+
new TextEncoder().encode(value).byteLength > limits.maxStringBytes
|
|
219
|
+
)
|
|
220
|
+
return limitError(
|
|
221
|
+
'maxStringBytes',
|
|
222
|
+
new TextEncoder().encode(value).byteLength,
|
|
223
|
+
limits.maxStringBytes,
|
|
224
|
+
);
|
|
225
|
+
const typedArray = typedArrayName(value);
|
|
226
|
+
if (typedArray !== undefined) {
|
|
227
|
+
const contents = value as PortableTypedArray;
|
|
228
|
+
if (contents.byteLength > limits.maxBufferBytes)
|
|
229
|
+
return limitError('maxBufferBytes', contents.byteLength, limits.maxBufferBytes);
|
|
230
|
+
if (contents.length > limits.maxArrayElements)
|
|
231
|
+
return limitError('maxArrayElements', contents.length, limits.maxArrayElements);
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
if (Array.isArray(value)) {
|
|
235
|
+
if (value.length > limits.maxArrayElements)
|
|
236
|
+
return limitError('maxArrayElements', value.length, limits.maxArrayElements);
|
|
237
|
+
for (const item of value) {
|
|
238
|
+
const problem = visit(item);
|
|
239
|
+
if (problem) return problem;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (value !== null && typeof value === 'object' && !(value instanceof Uint8Array))
|
|
243
|
+
for (const item of Object.values(value as Record<string, unknown>)) {
|
|
244
|
+
const problem = visit(item);
|
|
245
|
+
if (problem) return problem;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
};
|
|
249
|
+
for (const entity of packet.entities) {
|
|
250
|
+
operations += entity.components.length;
|
|
251
|
+
for (const component of entity.components) {
|
|
252
|
+
const problem = visit(component.data);
|
|
253
|
+
if (problem) return problem;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return operations > limits.maxComponentOperations
|
|
257
|
+
? limitError('maxComponentOperations', operations, limits.maxComponentOperations)
|
|
258
|
+
: null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parse(
|
|
262
|
+
bytes: Uint8Array,
|
|
263
|
+
): { readonly packet: ReplicationPacket } | { readonly error: NetError } {
|
|
264
|
+
const text = new TextDecoder().decode(bytes);
|
|
265
|
+
const separator = text.indexOf('\n');
|
|
266
|
+
if (separator < 0 || text.slice(0, separator) !== REPLICATION_PROTOCOL_PREFIX)
|
|
267
|
+
return { error: invalid('packet prefix does not match protocol-v2') };
|
|
268
|
+
try {
|
|
269
|
+
const decoded: unknown = JSON.parse(text.slice(separator + 1));
|
|
270
|
+
const revived = reviveTypedArrays(decoded);
|
|
271
|
+
if ('reason' in revived) return { error: invalid(revived.reason) };
|
|
272
|
+
if (revived.value === null || typeof revived.value !== 'object')
|
|
273
|
+
return { error: invalid('packet must be an object') };
|
|
274
|
+
const packet = revived.value as ReplicationPacket;
|
|
275
|
+
const reason = validatePacket(packet);
|
|
276
|
+
if (reason !== undefined) {
|
|
277
|
+
if (typeof packet.version === 'number' && packet.version !== REPLICATION_PROTOCOL_VERSION)
|
|
278
|
+
return {
|
|
279
|
+
error: new NetError({
|
|
280
|
+
code: 'protocol-unsupported-version',
|
|
281
|
+
expected: `protocol version ${REPLICATION_PROTOCOL_VERSION}`,
|
|
282
|
+
hint: 'upgrade the peer before sending replicated bytes',
|
|
283
|
+
detail: {
|
|
284
|
+
receivedVersion: packet.version,
|
|
285
|
+
supportedVersion: REPLICATION_PROTOCOL_VERSION,
|
|
286
|
+
},
|
|
287
|
+
}),
|
|
288
|
+
};
|
|
289
|
+
return { error: invalid(reason) };
|
|
290
|
+
}
|
|
291
|
+
return { packet };
|
|
292
|
+
} catch {
|
|
293
|
+
return { error: invalid('payload is not valid JSON') };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isDataPacket(packet: ReplicationPacket): packet is ReplicationDataPacket {
|
|
298
|
+
return packet.kind === 'baseline' || packet.kind === 'delta';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function encodeReplicationPacket(
|
|
302
|
+
packet: ReplicationPacket,
|
|
303
|
+
limits: ReplicationLimits,
|
|
304
|
+
): Result<Uint8Array, NetError> {
|
|
305
|
+
const reason = validatePacket(packet);
|
|
306
|
+
if (reason !== undefined) return err(invalid(reason));
|
|
307
|
+
const body = JSON.stringify(canonicalize(packet));
|
|
308
|
+
const bytes = new TextEncoder().encode(`${REPLICATION_PROTOCOL_PREFIX}\n${body}`);
|
|
309
|
+
const failure = isDataPacket(packet) ? validateLimits(packet, bytes, limits) : null;
|
|
310
|
+
return failure ? err(failure) : ok(bytes);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function decodeReplicationPacket(
|
|
314
|
+
bytes: Uint8Array,
|
|
315
|
+
limits: ReplicationLimits,
|
|
316
|
+
): Result<ReplicationPacket, NetError> {
|
|
317
|
+
if (bytes.byteLength > limits.maxMessageBytes)
|
|
318
|
+
return err(limitError('maxMessageBytes', bytes.byteLength, limits.maxMessageBytes));
|
|
319
|
+
const parsed = parse(bytes);
|
|
320
|
+
if ('error' in parsed) return err(parsed.error);
|
|
321
|
+
const failure = isDataPacket(parsed.packet) ? validateLimits(parsed.packet, bytes, limits) : null;
|
|
322
|
+
return failure ? err(failure) : ok(parsed.packet);
|
|
323
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type NetErrorDetailByCode = {
|
|
2
|
+
'handshake-profile-mismatch': {
|
|
3
|
+
readonly localFingerprint: string;
|
|
4
|
+
readonly remoteFingerprint: string;
|
|
5
|
+
};
|
|
6
|
+
'decode-invalid-payload': { readonly reason: string };
|
|
7
|
+
'decode-limit-exceeded': {
|
|
8
|
+
readonly limit: string;
|
|
9
|
+
readonly actual: number;
|
|
10
|
+
readonly maximum: number;
|
|
11
|
+
};
|
|
12
|
+
'ordering-invalid-tick': { readonly receivedTick: number; readonly lastTick: number };
|
|
13
|
+
'identity-invalid': { readonly id: number; readonly reason: string };
|
|
14
|
+
'schema-invalid': { readonly component: string; readonly reason: string };
|
|
15
|
+
'remap-unresolved-reference': { readonly id: number; readonly referencedId: number };
|
|
16
|
+
'apply-invariant-failed': { readonly reason: string };
|
|
17
|
+
'protocol-unsupported-version': {
|
|
18
|
+
readonly receivedVersion: number;
|
|
19
|
+
readonly supportedVersion: number;
|
|
20
|
+
};
|
|
21
|
+
'session-illegal-transition': {
|
|
22
|
+
readonly from: string;
|
|
23
|
+
readonly to: string;
|
|
24
|
+
};
|
|
25
|
+
'recovery-policy-invalid': {
|
|
26
|
+
readonly field: string;
|
|
27
|
+
readonly reason: string;
|
|
28
|
+
};
|
|
29
|
+
'recovery-rejected': { readonly reason: string };
|
|
30
|
+
'recovery-exhausted': {
|
|
31
|
+
readonly attempts: number;
|
|
32
|
+
readonly maxAttempts: number;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type NetErrorCode = keyof NetErrorDetailByCode;
|
|
37
|
+
export type NetErrorDetailFor<C extends NetErrorCode> = NetErrorDetailByCode[C];
|
|
38
|
+
export type NetErrorDetail = NetErrorDetailFor<NetErrorCode>;
|
|
39
|
+
|
|
40
|
+
class NetErrorClass extends Error {
|
|
41
|
+
readonly code: NetErrorCode;
|
|
42
|
+
readonly expected: string;
|
|
43
|
+
readonly hint: string;
|
|
44
|
+
readonly detail: NetErrorDetail;
|
|
45
|
+
constructor(args: {
|
|
46
|
+
code: NetErrorCode;
|
|
47
|
+
expected: string;
|
|
48
|
+
hint: string;
|
|
49
|
+
detail: NetErrorDetail;
|
|
50
|
+
}) {
|
|
51
|
+
super(`[NetError ${args.code}] expected: ${args.expected}; hint: ${args.hint}`);
|
|
52
|
+
this.name = 'NetError';
|
|
53
|
+
this.code = args.code;
|
|
54
|
+
this.expected = args.expected;
|
|
55
|
+
this.hint = args.hint;
|
|
56
|
+
this.detail = args.detail;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Variant<C extends NetErrorCode> = NetErrorClass & {
|
|
61
|
+
readonly code: C;
|
|
62
|
+
readonly detail: NetErrorDetailFor<C>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type NetError = {
|
|
66
|
+
[C in NetErrorCode]: Variant<C>;
|
|
67
|
+
}[NetErrorCode];
|
|
68
|
+
|
|
69
|
+
interface NetErrorConstructor {
|
|
70
|
+
new <C extends NetErrorCode>(args: {
|
|
71
|
+
code: C;
|
|
72
|
+
expected: string;
|
|
73
|
+
hint: string;
|
|
74
|
+
detail: NetErrorDetailFor<C>;
|
|
75
|
+
}): Variant<C>;
|
|
76
|
+
readonly prototype: NetErrorClass;
|
|
77
|
+
}
|
|
78
|
+
export const NetError: NetErrorConstructor = NetErrorClass as unknown as NetErrorConstructor;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
import { NetError } from './errors';
|
|
3
|
+
import type { ReplicationProfile } from './profile';
|
|
4
|
+
export function validateHandshake(
|
|
5
|
+
local: ReplicationProfile,
|
|
6
|
+
remote: ReplicationProfile,
|
|
7
|
+
): Result<void, NetError> {
|
|
8
|
+
if (local.fingerprint !== remote.fingerprint)
|
|
9
|
+
return err(
|
|
10
|
+
new NetError({
|
|
11
|
+
code: 'handshake-profile-mismatch',
|
|
12
|
+
expected: 'matching protocol, profile, and declared limits',
|
|
13
|
+
hint: 'use identical ordered replication components and limits on both peers',
|
|
14
|
+
detail: { localFingerprint: local.fingerprint, remoteFingerprint: remote.fingerprint },
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
return ok(undefined);
|
|
18
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Component } from '@forgeax/engine-ecs';
|
|
2
|
+
import { validateProfileComponents } from '@forgeax/engine-ecs/externalization';
|
|
3
|
+
import { componentSchema } from '@forgeax/engine-ecs/internal';
|
|
4
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
5
|
+
export interface ReplicationLimits {
|
|
6
|
+
readonly maxMessageBytes: number;
|
|
7
|
+
readonly maxEntities: number;
|
|
8
|
+
readonly maxComponentOperations: number;
|
|
9
|
+
readonly maxStringBytes: number;
|
|
10
|
+
readonly maxBufferBytes: number;
|
|
11
|
+
readonly maxArrayElements: number;
|
|
12
|
+
}
|
|
13
|
+
export const DEFAULT_REPLICATION_LIMITS: ReplicationLimits = {
|
|
14
|
+
maxMessageBytes: 64 * 1024,
|
|
15
|
+
maxEntities: 1024,
|
|
16
|
+
maxComponentOperations: 4096,
|
|
17
|
+
maxStringBytes: 4096,
|
|
18
|
+
maxBufferBytes: 16 * 1024,
|
|
19
|
+
maxArrayElements: 1024,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
import { NetError } from './errors';
|
|
23
|
+
|
|
24
|
+
export interface ReplicationProfile {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly entities: ReplicationEntityFilter;
|
|
27
|
+
readonly components: readonly Component[];
|
|
28
|
+
readonly limits: ReplicationLimits;
|
|
29
|
+
readonly fingerprint: string;
|
|
30
|
+
}
|
|
31
|
+
export interface ReplicationEntityFilter {
|
|
32
|
+
readonly with: readonly Component[];
|
|
33
|
+
readonly without?: readonly Component[];
|
|
34
|
+
}
|
|
35
|
+
export interface DefineReplicationOptions {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly entities: ReplicationEntityFilter;
|
|
38
|
+
readonly components: readonly Component[];
|
|
39
|
+
readonly limits?: Partial<ReplicationLimits>;
|
|
40
|
+
}
|
|
41
|
+
function hash(text: string): string {
|
|
42
|
+
let value = 2166136261;
|
|
43
|
+
for (const char of text) {
|
|
44
|
+
value ^= char.charCodeAt(0);
|
|
45
|
+
value = Math.imul(value, 16777619);
|
|
46
|
+
}
|
|
47
|
+
return (value >>> 0).toString(16).padStart(8, '0');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function immutableProfile(
|
|
51
|
+
options: DefineReplicationOptions,
|
|
52
|
+
limits: ReplicationLimits,
|
|
53
|
+
fingerprint: string,
|
|
54
|
+
): ReplicationProfile {
|
|
55
|
+
const entities: ReplicationEntityFilter = Object.freeze({
|
|
56
|
+
with: Object.freeze([...options.entities.with]),
|
|
57
|
+
...(options.entities.without === undefined
|
|
58
|
+
? {}
|
|
59
|
+
: { without: Object.freeze([...options.entities.without]) }),
|
|
60
|
+
});
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
name: options.name,
|
|
63
|
+
entities,
|
|
64
|
+
components: Object.freeze([...options.components]),
|
|
65
|
+
limits: Object.freeze({ ...limits }),
|
|
66
|
+
fingerprint,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function defineReplication(
|
|
71
|
+
options: DefineReplicationOptions,
|
|
72
|
+
): Result<ReplicationProfile, NetError> {
|
|
73
|
+
const portable = validateProfileComponents(options.components);
|
|
74
|
+
if (!portable.valid) {
|
|
75
|
+
const first = portable.errors[0];
|
|
76
|
+
if (first === undefined) {
|
|
77
|
+
return err(
|
|
78
|
+
new NetError({
|
|
79
|
+
code: 'schema-invalid',
|
|
80
|
+
expected: 'portable replication components',
|
|
81
|
+
hint: 'select only components accepted by the ECS externalization kernel',
|
|
82
|
+
detail: { component: '', reason: 'portable validation failed without a diagnostic' },
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return err(
|
|
87
|
+
new NetError({
|
|
88
|
+
code: 'schema-invalid',
|
|
89
|
+
expected: first.expected,
|
|
90
|
+
hint: first.hint,
|
|
91
|
+
detail: { component: first.component, reason: first.code },
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const limits: ReplicationLimits = { ...DEFAULT_REPLICATION_LIMITS, ...options.limits };
|
|
96
|
+
const signature = JSON.stringify({
|
|
97
|
+
name: options.name,
|
|
98
|
+
query: {
|
|
99
|
+
with: options.entities.with.map((component) => component.name),
|
|
100
|
+
...(options.entities.without === undefined
|
|
101
|
+
? {}
|
|
102
|
+
: { without: options.entities.without.map((component) => component.name) }),
|
|
103
|
+
},
|
|
104
|
+
components: options.components.map((component) => ({
|
|
105
|
+
name: component.name,
|
|
106
|
+
schema: componentSchema(component),
|
|
107
|
+
})),
|
|
108
|
+
limits,
|
|
109
|
+
});
|
|
110
|
+
return ok(immutableProfile(options, limits, hash(signature)));
|
|
111
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { SessionId } from '../session/recovery';
|
|
2
|
+
|
|
3
|
+
/** Replicated ECS entity operations owned by the protocol manifest. */
|
|
4
|
+
export type ReplicationEntityKind = 'upsert' | 'despawn';
|
|
5
|
+
|
|
6
|
+
export interface ReplicationComponentRecord {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly operation?: 'replace' | 'remove';
|
|
9
|
+
readonly data: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ReplicationEntityRecord {
|
|
13
|
+
readonly id: number;
|
|
14
|
+
readonly kind: ReplicationEntityKind;
|
|
15
|
+
readonly components: readonly ReplicationComponentRecord[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ReplicationPacketKind =
|
|
19
|
+
| 'session-open'
|
|
20
|
+
| 'session-resume'
|
|
21
|
+
| 'baseline'
|
|
22
|
+
| 'delta'
|
|
23
|
+
| 'ack'
|
|
24
|
+
| 'rejection';
|
|
25
|
+
|
|
26
|
+
/** Control packet that opens or resumes one application session. */
|
|
27
|
+
export interface ReplicationSessionPacket {
|
|
28
|
+
readonly version: 2;
|
|
29
|
+
readonly kind: 'session-open' | 'session-resume';
|
|
30
|
+
readonly sessionId: SessionId;
|
|
31
|
+
readonly epoch: number;
|
|
32
|
+
readonly sequence: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Shared fields for baseline and delta data packets. */
|
|
36
|
+
export interface ReplicationDataPacketBase {
|
|
37
|
+
readonly version: 2;
|
|
38
|
+
readonly kind: 'baseline' | 'delta';
|
|
39
|
+
readonly sessionId: SessionId;
|
|
40
|
+
readonly epoch: number;
|
|
41
|
+
readonly sequence: number;
|
|
42
|
+
readonly tick: number;
|
|
43
|
+
readonly fingerprint: string;
|
|
44
|
+
readonly entities: readonly ReplicationEntityRecord[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Complete authoritative baseline; sequence one is required for every epoch. */
|
|
48
|
+
export interface ReplicationBaselinePacket extends ReplicationDataPacketBase {
|
|
49
|
+
readonly kind: 'baseline';
|
|
50
|
+
readonly sequence: 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Ordered authoritative delta after the accepted baseline. */
|
|
54
|
+
export interface ReplicationDeltaPacket extends ReplicationDataPacketBase {
|
|
55
|
+
readonly kind: 'delta';
|
|
56
|
+
readonly sequence: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ReplicationDataPacket = ReplicationBaselinePacket | ReplicationDeltaPacket;
|
|
60
|
+
|
|
61
|
+
export interface ReplicationAckPacket {
|
|
62
|
+
readonly version: 2;
|
|
63
|
+
readonly kind: 'ack';
|
|
64
|
+
readonly sessionId: SessionId;
|
|
65
|
+
readonly epoch: number;
|
|
66
|
+
readonly acknowledgedSequence: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ReplicationRejectionPacket {
|
|
70
|
+
readonly version: 2;
|
|
71
|
+
readonly kind: 'rejection';
|
|
72
|
+
readonly sessionId: SessionId;
|
|
73
|
+
readonly epoch: number;
|
|
74
|
+
readonly sequence: number;
|
|
75
|
+
readonly rejectedKind: ReplicationPacketKind;
|
|
76
|
+
readonly reason: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ReplicationPacket =
|
|
80
|
+
| ReplicationSessionPacket
|
|
81
|
+
| ReplicationBaselinePacket
|
|
82
|
+
| ReplicationDeltaPacket
|
|
83
|
+
| ReplicationAckPacket
|
|
84
|
+
| ReplicationRejectionPacket;
|
|
85
|
+
|
|
86
|
+
export type ReplicationDataPacketKind = ReplicationDataPacket['kind'];
|