bedrock-portal-nethernet 2.1.0 → 2.2.0

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.
@@ -1,17 +1,29 @@
1
1
  import { Connection } from './connection';
2
2
  import { Signal } from '../signaling/signal';
3
3
  import { SignalStructure } from '../signaling/struct';
4
+ type PendingCandidate = {
5
+ signal: SignalStructure;
6
+ attempts: number;
7
+ };
4
8
  export declare class Server {
5
9
  networkId: bigint;
6
10
  connectionId: bigint;
7
11
  signaling: Signal;
8
12
  connections: Map<bigint, Connection>;
13
+ pendingCandidates: Map<bigint, PendingCandidate[]>;
14
+ remoteDescriptionReady: Set<bigint>;
15
+ pendingCandidateTimers: Map<bigint, NodeJS.Timeout>;
9
16
  onOpenConnection: (conn: Connection) => void;
10
17
  onCloseConnection: (id: bigint, reason: string) => void;
11
18
  onEncapsulated: (packet: Buffer, id: bigint) => void;
12
19
  constructor(signaling: Signal, networkId?: bigint, connectionId?: bigint);
13
20
  handleCandidate(signal: SignalStructure): Promise<void>;
21
+ queueCandidate(signal: SignalStructure, reason?: string): void;
22
+ schedulePendingCandidateFlush(connectionId: bigint): void;
23
+ flushPendingCandidates(connectionId: bigint): void;
24
+ requeueCandidate(candidate: PendingCandidate, remaining: PendingCandidate[], reason: string): void;
14
25
  handleOffer(signal: SignalStructure): Promise<void>;
15
26
  listen(): Promise<void>;
16
27
  close(): void;
17
28
  }
29
+ export {};
@@ -5,6 +5,8 @@ const node_datachannel_1 = require("node-datachannel");
5
5
  const connection_1 = require("./connection");
6
6
  const struct_1 = require("../signaling/struct");
7
7
  const debugFn = require('debug')('bedrock-portal-nethernet');
8
+ const MAX_PENDING_CANDIDATE_ATTEMPTS = 50;
9
+ const PENDING_CANDIDATE_RETRY_MS = 100;
8
10
  const getRandomUint64 = () => {
9
11
  const high = Math.floor(Math.random() * 0xFFFFFFFF);
10
12
  const low = Math.floor(Math.random() * 0xFFFFFFFF);
@@ -15,6 +17,9 @@ class Server {
15
17
  connectionId;
16
18
  signaling;
17
19
  connections;
20
+ pendingCandidates;
21
+ remoteDescriptionReady;
22
+ pendingCandidateTimers;
18
23
  onOpenConnection;
19
24
  onCloseConnection;
20
25
  onEncapsulated;
@@ -23,18 +28,78 @@ class Server {
23
28
  this.networkId = networkId;
24
29
  this.connectionId = connectionId;
25
30
  this.connections = new Map();
31
+ this.pendingCandidates = new Map();
32
+ this.remoteDescriptionReady = new Set();
33
+ this.pendingCandidateTimers = new Map();
26
34
  this.onOpenConnection = () => { };
27
35
  this.onCloseConnection = () => { };
28
36
  this.onEncapsulated = () => { };
29
37
  }
30
38
  async handleCandidate(signal) {
31
39
  const conn = this.connections.get(signal.connectionId);
32
- if (conn) {
40
+ if (!conn || !this.remoteDescriptionReady.has(signal.connectionId)) {
41
+ this.queueCandidate(signal);
42
+ return;
43
+ }
44
+ try {
33
45
  conn.rtcConnection.addRemoteCandidate(signal.data, '0');
34
46
  }
47
+ catch (error) {
48
+ this.queueCandidate(signal, formatCandidateError(error));
49
+ }
50
+ }
51
+ queueCandidate(signal, reason = 'connection is not ready') {
52
+ const candidates = this.pendingCandidates.get(signal.connectionId) || [];
53
+ candidates.push({ signal, attempts: 0 });
54
+ this.pendingCandidates.set(signal.connectionId, candidates);
55
+ debugFn('Queued ICE candidate', signal.connectionId, reason);
56
+ this.schedulePendingCandidateFlush(signal.connectionId);
57
+ }
58
+ schedulePendingCandidateFlush(connectionId) {
59
+ if (this.pendingCandidateTimers.has(connectionId))
60
+ return;
61
+ const timer = setTimeout(() => {
62
+ this.pendingCandidateTimers.delete(connectionId);
63
+ this.flushPendingCandidates(connectionId);
64
+ }, PENDING_CANDIDATE_RETRY_MS);
65
+ timer.unref?.();
66
+ this.pendingCandidateTimers.set(connectionId, timer);
67
+ }
68
+ flushPendingCandidates(connectionId) {
69
+ const candidates = this.pendingCandidates.get(connectionId);
70
+ if (!candidates?.length)
71
+ return;
72
+ const conn = this.connections.get(connectionId);
73
+ const remaining = [];
74
+ for (const candidate of candidates) {
75
+ if (!conn || !this.remoteDescriptionReady.has(connectionId)) {
76
+ this.requeueCandidate(candidate, remaining, 'connection is not ready');
77
+ continue;
78
+ }
79
+ try {
80
+ conn.rtcConnection.addRemoteCandidate(candidate.signal.data, '0');
81
+ }
82
+ catch (error) {
83
+ this.requeueCandidate(candidate, remaining, formatCandidateError(error));
84
+ }
85
+ }
86
+ if (remaining.length) {
87
+ this.pendingCandidates.set(connectionId, remaining);
88
+ this.schedulePendingCandidateFlush(connectionId);
89
+ }
35
90
  else {
36
- debugFn('Received candidate for unknown connection', signal);
91
+ this.pendingCandidates.delete(connectionId);
92
+ }
93
+ }
94
+ requeueCandidate(candidate, remaining, reason) {
95
+ if (candidate.attempts >= MAX_PENDING_CANDIDATE_ATTEMPTS) {
96
+ debugFn('Dropping ICE candidate after retries', candidate.signal.connectionId, reason);
97
+ return;
37
98
  }
99
+ remaining.push({
100
+ signal: candidate.signal,
101
+ attempts: candidate.attempts + 1,
102
+ });
38
103
  }
39
104
  async handleOffer(signal) {
40
105
  if (!this.signaling.credentials) {
@@ -55,10 +120,15 @@ class Server {
55
120
  rtcConnection.onIceStateChange(state => {
56
121
  if (state === 'connected')
57
122
  this.onOpenConnection(connection);
58
- if (state === 'disconnected')
123
+ if (state === 'disconnected') {
124
+ this.remoteDescriptionReady.delete(signal.connectionId);
125
+ this.pendingCandidates.delete(signal.connectionId);
59
126
  this.onCloseConnection(signal.connectionId, 'disconnected');
127
+ }
60
128
  });
61
129
  rtcConnection.setRemoteDescription(signal.data, 'offer');
130
+ this.remoteDescriptionReady.add(signal.connectionId);
131
+ this.flushPendingCandidates(signal.connectionId);
62
132
  const answer = rtcConnection.localDescription();
63
133
  if (!answer) {
64
134
  throw new Error('No answer');
@@ -70,10 +140,14 @@ class Server {
70
140
  this.signaling.on('signal', (signal) => {
71
141
  switch (signal.type) {
72
142
  case struct_1.SignalType.ConnectRequest:
73
- this.handleOffer(signal);
143
+ this.handleOffer(signal).catch(error => {
144
+ debugFn('Failed to handle connect offer', signal, error);
145
+ });
74
146
  break;
75
147
  case struct_1.SignalType.CandidateAdd:
76
- this.handleCandidate(signal);
148
+ this.handleCandidate(signal).catch(error => {
149
+ debugFn('Failed to handle ICE candidate', signal, error);
150
+ });
77
151
  break;
78
152
  default:
79
153
  debugFn('Received signal for unknown type', signal);
@@ -81,9 +155,18 @@ class Server {
81
155
  });
82
156
  }
83
157
  close() {
158
+ for (const timer of this.pendingCandidateTimers.values()) {
159
+ clearTimeout(timer);
160
+ }
161
+ this.pendingCandidateTimers.clear();
162
+ this.pendingCandidates.clear();
163
+ this.remoteDescriptionReady.clear();
84
164
  for (const conn of this.connections.values()) {
85
165
  conn.close();
86
166
  }
87
167
  }
88
168
  }
89
169
  exports.Server = Server;
170
+ function formatCandidateError(error) {
171
+ return error instanceof Error ? error.message : String(error);
172
+ }
package/dist/options.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CompressionAlgorithm } from './transforms/framer';
2
2
  export type Options = {
3
3
  version: string;
4
+ signalingMode: 'rpc' | 'legacy';
4
5
  autoInitPlayer: boolean;
5
6
  offline: boolean;
6
7
  connectTimeout: number;
@@ -9,14 +10,5 @@ export type Options = {
9
10
  compressionThreshold: number;
10
11
  protocolVersion: number;
11
12
  };
12
- export declare const CURRENT_VERSION = "1.26.10";
13
- export declare const defaultOptions: {
14
- version: string;
15
- autoInitPlayer: boolean;
16
- offline: boolean;
17
- connectTimeout: number;
18
- compressionAlgorithm: CompressionAlgorithm;
19
- compressionLevel: number;
20
- compressionThreshold: number;
21
- protocolVersion: number;
22
- };
13
+ export declare const CURRENT_VERSION = "1.26.20";
14
+ export declare const defaultOptions: Options;
package/dist/options.js CHANGED
@@ -3,10 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.defaultOptions = exports.CURRENT_VERSION = void 0;
4
4
  const framer_1 = require("./transforms/framer");
5
5
  // Currently supported verson. Note, clients with newer versions can still connect as long as data is in minecraft-data
6
- exports.CURRENT_VERSION = '1.26.10';
6
+ exports.CURRENT_VERSION = '1.26.20';
7
7
  exports.defaultOptions = {
8
8
  // https://minecraft.wiki/w/Protocol_version#Bedrock_Edition_2
9
9
  version: exports.CURRENT_VERSION,
10
+ signalingMode: 'rpc',
10
11
  // client: If we should send SetPlayerInitialized to the server after getting play_status spawn.
11
12
  // if this is disabled, no 'spawn' event will be emitted, you should manually set
12
13
  // client.status to ClientStatus.Initialized after sending the init packet.
@@ -22,5 +23,5 @@ exports.defaultOptions = {
22
23
  // server: If true, only compress if a payload is larger than compressionThreshold
23
24
  compressionThreshold: 512,
24
25
  // server and client: The protocol version to use
25
- protocolVersion: 944,
26
+ protocolVersion: 975,
26
27
  };
package/dist/server.js CHANGED
@@ -91,7 +91,7 @@ class Server extends tiny_typed_emitter_1.TypedEmitter {
91
91
  process.nextTick(() => client.onDecryptedPacket(buffer));
92
92
  };
93
93
  async listen(auth, networkId) {
94
- this.signaling = new signal_1.Signal(auth, networkId, this.options.version);
94
+ this.signaling = new signal_1.Signal(auth, networkId, this.options.version, this.options.signalingMode);
95
95
  this.nethernet = new server_1.Server(this.signaling, networkId);
96
96
  await this.nethernet.listen();
97
97
  this.nethernet.onOpenConnection = this.onOpenConnection;
@@ -8,7 +8,6 @@ const tiny_typed_emitter_1 = require("tiny-typed-emitter");
8
8
  const loginVerify_1 = __importDefault(require("./handshake/loginVerify"));
9
9
  const keyExchange_1 = require("./handshake/keyExchange");
10
10
  const util_1 = require("./datatypes/util");
11
- const options_1 = require("./options");
12
11
  const framer_1 = require("./transforms/framer");
13
12
  const debug = require('debug')('bedrock-portal-nethernet');
14
13
  exports.ClientStatus = {
@@ -103,7 +102,7 @@ class Player extends tiny_typed_emitter_1.TypedEmitter {
103
102
  // return false
104
103
  // }
105
104
  // }
106
- if (clientVersion < Number(options_1.CURRENT_VERSION)) {
105
+ if (clientVersion < this.server.options.protocolVersion) {
107
106
  this.sendDisconnectStatus('failed_client'); // client too old
108
107
  return false;
109
108
  }
@@ -1,27 +1,45 @@
1
1
  import type { Authflow } from 'prismarine-auth';
2
2
  import { EventEmitter } from 'events';
3
3
  import { Data, ErrorEvent, WebSocket } from 'ws';
4
- import { SignalStructure } from './struct';
4
+ import { NetworkId, SignalStructure } from './struct';
5
+ type TurnServer = {
6
+ hostname: string;
7
+ port: number;
8
+ username?: string;
9
+ password?: string;
10
+ };
11
+ type JsonObject = Record<string, unknown>;
5
12
  export declare class Signal extends EventEmitter {
6
13
  ws: WebSocket | null;
7
- networkId: bigint;
8
- credentials: {
9
- hostname: string;
10
- port: number;
11
- username: string;
12
- password: string;
13
- }[] | null;
14
+ networkId: NetworkId;
15
+ credentials: TurnServer[] | null;
14
16
  private authflow;
15
17
  private version;
16
18
  private pingInterval;
17
19
  private retryCount;
18
- constructor(authflow: Authflow, networkId: bigint, version: string);
20
+ private pendingRequests;
21
+ private mode;
22
+ constructor(authflow: Authflow, networkId: NetworkId, version: string, signalingMode?: 'rpc' | 'legacy');
19
23
  connect(): Promise<void>;
20
24
  destroy(resume?: boolean): Promise<void>;
21
25
  init(): Promise<void>;
22
26
  onOpen(): void;
23
27
  onError(err: ErrorEvent): void;
24
28
  onClose(code: number, reason: string): void;
29
+ handleCloseCleanup(code: number, reason: string): void;
30
+ clearPing(): void;
31
+ rejectPending(error: Error): void;
32
+ startLegacyPing(): void;
33
+ onRpcOpen(): void;
25
34
  onMessage(res: Data): void;
35
+ onLegacyMessage(message: JsonObject): void;
36
+ onRpcMessage(message: JsonObject): void;
37
+ handleRpcResponse(message: JsonObject): void;
38
+ handleRpcRequest(message: JsonObject): void;
39
+ processIncomingRpcMessage(message: JsonObject): void;
40
+ sendRpcDeliveryAck(target: string, messageId: string): Promise<unknown>;
41
+ sendJsonRpcRequest(method: string, params: unknown): Promise<unknown>;
42
+ sendJsonRpcResult(id: string | number, result: unknown): void;
26
43
  write(signal: SignalStructure): void;
27
44
  }
45
+ export {};
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.Signal = void 0;
7
+ const crypto_1 = require("crypto");
7
8
  const debug_1 = __importDefault(require("debug"));
8
9
  const json_bigint_1 = require("json-bigint");
9
10
  const events_1 = require("events");
@@ -14,6 +15,18 @@ const MessageType = {
14
15
  Signal: 1,
15
16
  Credentials: 2,
16
17
  };
18
+ const Rpc = {
19
+ TurnAuth: 'Signaling_TurnAuth_v1_0',
20
+ SendMessage: 'Signaling_SendClientMessage_v1_0',
21
+ ReceiveMessage: 'Signaling_ReceiveMessage_v1_0',
22
+ Ping: 'System_Ping_v1_0',
23
+ Pong: 'System_Pong_v1_0',
24
+ WebRtc: 'Signaling_WebRtc_v1_0',
25
+ Delivery: 'Signaling_DeliveryNotification_V1_0',
26
+ };
27
+ const RPC_SIGNAL_URL = 'wss://signal.franchise.minecraft-services.net/ws/v1.0/messaging/connect';
28
+ const LEGACY_SIGNAL_URL = 'wss://signal.franchise.minecraft-services.net/ws/v1.0/signaling';
29
+ const SIGNALING_USER_AGENT = 'libHttpClient/1.0.0.0';
17
30
  const debug = (0, debug_1.default)('bedrock-portal-nethernet');
18
31
  class Signal extends events_1.EventEmitter {
19
32
  ws;
@@ -23,7 +36,9 @@ class Signal extends events_1.EventEmitter {
23
36
  version;
24
37
  pingInterval;
25
38
  retryCount;
26
- constructor(authflow, networkId, version) {
39
+ pendingRequests;
40
+ mode;
41
+ constructor(authflow, networkId, version, signalingMode = 'rpc') {
27
42
  super();
28
43
  this.authflow = authflow;
29
44
  this.networkId = networkId;
@@ -32,6 +47,8 @@ class Signal extends events_1.EventEmitter {
32
47
  this.credentials = null;
33
48
  this.pingInterval = null;
34
49
  this.retryCount = 0;
50
+ this.pendingRequests = new Map();
51
+ this.mode = signalingMode;
35
52
  }
36
53
  async connect() {
37
54
  if (this.ws?.readyState === ws_1.WebSocket.OPEN)
@@ -41,22 +58,17 @@ class Signal extends events_1.EventEmitter {
41
58
  }
42
59
  async destroy(resume = false) {
43
60
  debug('Disconnecting from Signal');
44
- if (this.pingInterval) {
45
- clearInterval(this.pingInterval);
46
- this.pingInterval = null;
47
- }
61
+ this.clearPing();
62
+ this.rejectPending(new Error('Signal disconnected'));
48
63
  if (this.ws) {
49
64
  this.ws.onmessage = null;
50
65
  this.ws.onclose = null;
51
- const shouldClose = this.ws.readyState === ws_1.WebSocket.OPEN;
66
+ const shouldClose = this.ws.readyState === ws_1.WebSocket.OPEN || this.ws.readyState === ws_1.WebSocket.CONNECTING;
52
67
  if (shouldClose) {
53
- let outerResolve;
54
- const promise = new Promise((resolve) => {
55
- outerResolve = resolve;
68
+ await new Promise((resolve) => {
69
+ this.ws.onclose = resolve;
70
+ this.ws.close(1000, 'Normal Closure');
56
71
  });
57
- this.ws.onclose = outerResolve;
58
- this.ws.close(1000, 'Normal Closure');
59
- await promise;
60
72
  }
61
73
  this.ws.onerror = null;
62
74
  }
@@ -67,20 +79,27 @@ class Signal extends events_1.EventEmitter {
67
79
  async init() {
68
80
  const xbl = await this.authflow.getMinecraftBedrockServicesToken({ version: this.version });
69
81
  debug('Fetched XBL Token', xbl);
70
- const address = `wss://signal.franchise.minecraft-services.net/ws/v1.0/signaling/${this.networkId}`;
82
+ this.mode = this.mode === 'legacy' ? 'legacy' : 'rpc';
83
+ const address = this.mode === 'legacy' ? `${LEGACY_SIGNAL_URL}/${this.networkId}` : RPC_SIGNAL_URL;
71
84
  debug('Connecting to Signal', address);
72
- const ws = new ws_1.WebSocket(address, {
73
- headers: { Authorization: xbl.mcToken },
74
- });
75
- this.pingInterval = setInterval(() => {
76
- if (this.ws && this.ws.readyState === ws_1.WebSocket.OPEN) {
77
- this.ws.send(JSON.stringify({ Type: MessageType.RequestPing }));
78
- }
79
- }, 5000);
85
+ const headers = this.mode === 'legacy'
86
+ ? { Authorization: xbl.mcToken }
87
+ : {
88
+ 'Authorization': xbl.mcToken,
89
+ 'User-Agent': SIGNALING_USER_AGENT,
90
+ 'session-id': (0, crypto_1.randomUUID)(),
91
+ 'request-id': (0, crypto_1.randomUUID)(),
92
+ };
93
+ const ws = new ws_1.WebSocket(address, { headers });
80
94
  ws.onopen = () => {
81
95
  this.onOpen();
96
+ if (this.mode === 'legacy')
97
+ this.startLegacyPing();
98
+ else
99
+ this.onRpcOpen();
82
100
  };
83
101
  ws.onclose = (event) => {
102
+ this.handleCloseCleanup(event.code, event.reason);
84
103
  this.onClose(event.code, event.reason);
85
104
  };
86
105
  ws.onerror = (event) => {
@@ -111,14 +130,79 @@ class Signal extends events_1.EventEmitter {
111
130
  }
112
131
  }
113
132
  }
133
+ handleCloseCleanup(code, reason) {
134
+ this.clearPing();
135
+ this.rejectPending(new Error(`Signal closed with code ${code}: ${reason || 'none'}`));
136
+ }
137
+ clearPing() {
138
+ if (this.pingInterval) {
139
+ clearInterval(this.pingInterval);
140
+ this.pingInterval = null;
141
+ }
142
+ }
143
+ rejectPending(error) {
144
+ for (const pending of this.pendingRequests.values()) {
145
+ clearTimeout(pending.timer);
146
+ pending.reject(error);
147
+ }
148
+ this.pendingRequests.clear();
149
+ }
150
+ startLegacyPing() {
151
+ this.clearPing();
152
+ this.pingInterval = setInterval(() => {
153
+ if (this.ws && this.ws.readyState === ws_1.WebSocket.OPEN) {
154
+ this.ws.send(JSON.stringify({ Type: MessageType.RequestPing }));
155
+ }
156
+ }, 5000);
157
+ this.pingInterval.unref?.();
158
+ }
159
+ onRpcOpen() {
160
+ this.clearPing();
161
+ this.pingInterval = setInterval(() => {
162
+ if (this.ws && this.ws.readyState === ws_1.WebSocket.OPEN) {
163
+ this.sendJsonRpcRequest(Rpc.Ping, {}).catch(error => {
164
+ debug('RPC ping failed', error);
165
+ });
166
+ }
167
+ }, 50000);
168
+ this.pingInterval.unref?.();
169
+ this.sendJsonRpcRequest(Rpc.TurnAuth, {})
170
+ .then((response) => {
171
+ this.credentials = parseTurnServers(response);
172
+ this.emit('credentials', this.credentials);
173
+ })
174
+ .catch((error) => {
175
+ debug('Failed to fetch JSON-RPC TURN credentials', error);
176
+ if (this.listenerCount('error') > 0)
177
+ this.emit('error', error);
178
+ });
179
+ }
114
180
  onMessage(res) {
181
+ if (Buffer.isBuffer(res))
182
+ res = res.toString('utf8');
115
183
  if (!(typeof res === 'string'))
116
184
  return debug('Recieved non-string message', res);
117
- const message = JSON.parse(res);
185
+ let message;
186
+ try {
187
+ message = JSON.parse(res);
188
+ }
189
+ catch (error) {
190
+ debug('Failed to parse signaling message', res, error);
191
+ return;
192
+ }
118
193
  debug('Recieved message', message);
194
+ if (isRecord(message) && 'Type' in message) {
195
+ return this.onLegacyMessage(message);
196
+ }
197
+ if (isRecord(message)) {
198
+ return this.onRpcMessage(message);
199
+ }
200
+ }
201
+ onLegacyMessage(message) {
119
202
  switch (message.Type) {
120
203
  case MessageType.Credentials: {
121
- if (message.From != 'Server') {
204
+ const from = getFirstString(message, ['From']);
205
+ if (from != 'Server') {
122
206
  debug('received credentials from non-Server', 'message', message);
123
207
  return;
124
208
  }
@@ -127,7 +211,11 @@ class Signal extends events_1.EventEmitter {
127
211
  break;
128
212
  }
129
213
  case MessageType.Signal: {
130
- const signal = struct_1.SignalStructure.fromString(message.Message, BigInt(message.From));
214
+ const from = getFirstString(message, ['From']);
215
+ const payload = getFirstString(message, ['Message']);
216
+ if (!from || !payload)
217
+ return;
218
+ const signal = struct_1.SignalStructure.fromString(payload, parseNetworkId(from));
131
219
  this.emit('signal', signal);
132
220
  break;
133
221
  }
@@ -136,31 +224,210 @@ class Signal extends events_1.EventEmitter {
136
224
  }
137
225
  }
138
226
  }
227
+ onRpcMessage(message) {
228
+ if (Object.prototype.hasOwnProperty.call(message, 'result') || (message.error && Object.prototype.hasOwnProperty.call(message, 'id'))) {
229
+ this.handleRpcResponse(message);
230
+ }
231
+ else if (message.method) {
232
+ this.handleRpcRequest(message);
233
+ }
234
+ }
235
+ handleRpcResponse(message) {
236
+ if (message.id === undefined || message.id === null)
237
+ return;
238
+ const id = String(message.id);
239
+ const pending = this.pendingRequests.get(id);
240
+ if (!pending)
241
+ return;
242
+ clearTimeout(pending.timer);
243
+ this.pendingRequests.delete(id);
244
+ if (message.error) {
245
+ const rpcError = isRecord(message.error) ? message.error : null;
246
+ const errorMessage = typeof rpcError?.message === 'string' ? rpcError.message : JSON.stringify(message.error);
247
+ pending.reject(new Error(errorMessage));
248
+ return;
249
+ }
250
+ pending.resolve(message.result || {});
251
+ }
252
+ handleRpcRequest(message) {
253
+ const id = message.id;
254
+ const hasResponseId = typeof id === 'string' || typeof id === 'number';
255
+ switch (typeof message.method === 'string' ? message.method : undefined) {
256
+ case Rpc.ReceiveMessage: {
257
+ if (hasResponseId)
258
+ this.sendJsonRpcResult(id, null);
259
+ const params = Array.isArray(message.params) ? message.params : [];
260
+ for (const item of params) {
261
+ this.processIncomingRpcMessage(item);
262
+ }
263
+ break;
264
+ }
265
+ case Rpc.Ping:
266
+ case Rpc.Pong: {
267
+ if (hasResponseId)
268
+ this.sendJsonRpcResult(id, null);
269
+ break;
270
+ }
271
+ default:
272
+ debug('Unhandled RPC signaling method', message.method, message);
273
+ }
274
+ }
275
+ processIncomingRpcMessage(message) {
276
+ const from = getFirstString(message, ['From', 'from']);
277
+ const rawInner = getFirstString(message, ['Message', 'message']);
278
+ const messageId = getFirstString(message, ['Id', 'id']) || (0, crypto_1.randomUUID)();
279
+ if (!from || !rawInner) {
280
+ debug('Ignoring malformed RPC signal message', message);
281
+ return;
282
+ }
283
+ this.sendRpcDeliveryAck(from, messageId).catch(error => {
284
+ debug('Failed to send RPC delivery acknowledgement', error);
285
+ });
286
+ let inner;
287
+ try {
288
+ inner = JSON.parse(rawInner);
289
+ }
290
+ catch (error) {
291
+ debug('Failed to parse inner RPC signaling message', rawInner, error);
292
+ return;
293
+ }
294
+ if (!isRecord(inner) || inner.method !== Rpc.WebRtc) {
295
+ debug('Ignoring non-WebRTC RPC inner message', isRecord(inner) ? inner.method : undefined);
296
+ return;
297
+ }
298
+ const params = isRecord(inner.params) ? inner.params : null;
299
+ const payload = params?.message;
300
+ if (typeof payload !== 'string') {
301
+ debug('Ignoring RPC WebRTC message without string payload', inner);
302
+ return;
303
+ }
304
+ const signal = struct_1.SignalStructure.fromString(payload, parseNetworkId(from));
305
+ signal.rpcFrom = from;
306
+ this.emit('signal', signal);
307
+ }
308
+ sendRpcDeliveryAck(target, messageId) {
309
+ const innerMessage = {
310
+ params: { messageId },
311
+ jsonrpc: '2.0',
312
+ method: Rpc.Delivery,
313
+ };
314
+ return this.sendJsonRpcRequest(Rpc.SendMessage, {
315
+ toPlayerId: String(target),
316
+ messageId: (0, crypto_1.randomUUID)(),
317
+ message: JSON.stringify(innerMessage),
318
+ });
319
+ }
320
+ sendJsonRpcRequest(method, params) {
321
+ if (!this.ws || this.ws.readyState !== ws_1.WebSocket.OPEN) {
322
+ return Promise.reject(new Error('WebSocket not connected'));
323
+ }
324
+ const id = (0, crypto_1.randomUUID)();
325
+ const request = {
326
+ params,
327
+ jsonrpc: '2.0',
328
+ method,
329
+ id,
330
+ };
331
+ const promise = new Promise((resolve, reject) => {
332
+ const timer = setTimeout(() => {
333
+ this.pendingRequests.delete(id);
334
+ reject(new Error(`Timed out waiting for RPC response to ${method}`));
335
+ }, 30000);
336
+ timer.unref?.();
337
+ this.pendingRequests.set(id, { resolve, reject, timer });
338
+ });
339
+ this.ws.send(JSON.stringify(request));
340
+ return promise;
341
+ }
342
+ sendJsonRpcResult(id, result) {
343
+ if (!this.ws || this.ws.readyState !== ws_1.WebSocket.OPEN)
344
+ return;
345
+ this.ws.send(JSON.stringify({
346
+ id,
347
+ result,
348
+ jsonrpc: '2.0',
349
+ }));
350
+ }
139
351
  write(signal) {
140
352
  if (!this.ws)
141
353
  throw new Error('WebSocket not connected');
354
+ if (this.mode === 'rpc') {
355
+ const target = signal.rpcFrom || stringifyNetworkId(signal.networkId);
356
+ const innerMessage = {
357
+ params: {
358
+ netherNetId: stringifyNetworkId(this.networkId),
359
+ message: signal.toString(),
360
+ },
361
+ jsonrpc: '2.0',
362
+ method: Rpc.WebRtc,
363
+ };
364
+ this.sendJsonRpcRequest(Rpc.SendMessage, {
365
+ toPlayerId: String(target),
366
+ messageId: (0, crypto_1.randomUUID)(),
367
+ message: JSON.stringify(innerMessage),
368
+ }).catch(error => {
369
+ debug('Failed to send JSON-RPC signal', target, error);
370
+ });
371
+ return;
372
+ }
142
373
  const message = (0, json_bigint_1.stringify)({ Type: MessageType.Signal, To: signal.networkId, Message: signal.toString() });
143
374
  debug('Sending Signal', message);
144
375
  this.ws.send(message);
145
376
  }
146
377
  }
147
378
  exports.Signal = Signal;
379
+ function parseNetworkId(value) {
380
+ if (typeof value === 'bigint')
381
+ return value;
382
+ const stringValue = String(value);
383
+ if (/^[0-9]+$/.test(stringValue)) {
384
+ try {
385
+ return BigInt(stringValue);
386
+ }
387
+ catch (_error) {
388
+ return stringValue;
389
+ }
390
+ }
391
+ return stringValue;
392
+ }
393
+ function stringifyNetworkId(value) {
394
+ return typeof value === 'bigint' ? value.toString() : String(value);
395
+ }
396
+ function isRecord(value) {
397
+ return Boolean(value) && typeof value === 'object';
398
+ }
399
+ function getFirstString(source, keys) {
400
+ if (!isRecord(source))
401
+ return undefined;
402
+ for (const key of keys) {
403
+ const value = source[key];
404
+ if (value !== undefined && value !== null)
405
+ return String(value);
406
+ }
407
+ return undefined;
408
+ }
148
409
  function parseTurnServers(dataString) {
149
410
  const servers = [];
150
- const data = JSON.parse(dataString);
151
- if (!data.TurnAuthServers)
411
+ const data = typeof dataString === 'string' ? JSON.parse(dataString) : dataString;
412
+ if (!isRecord(data))
152
413
  return servers;
153
- for (const server of data.TurnAuthServers) {
154
- if (!server.Urls)
414
+ const turnServers = data.TurnAuthServers || data.turnAuthServers;
415
+ if (!Array.isArray(turnServers))
416
+ return servers;
417
+ for (const server of turnServers) {
418
+ if (!isRecord(server))
419
+ continue;
420
+ const urls = server.Urls || server.urls;
421
+ if (!Array.isArray(urls))
155
422
  continue;
156
- for (const url of server.Urls) {
157
- const match = url.match(/(stun|turn):([^:]+):(\d+)/);
423
+ for (const url of urls) {
424
+ const match = String(url).match(/^(stun|turn):(?:\[([^\]]+)\]|([^:/?]+)):(\d+)/);
158
425
  if (match) {
159
426
  servers.push({
160
- hostname: match[2],
161
- port: parseInt(match[3], 10),
162
- username: server.Username || undefined,
163
- password: server.Password || undefined,
427
+ hostname: match[2] || match[3],
428
+ port: parseInt(match[4], 10),
429
+ username: getFirstString(server, ['Username', 'username']),
430
+ password: getFirstString(server, ['Password', 'password', 'Credential', 'credential']),
164
431
  });
165
432
  }
166
433
  }
@@ -1,3 +1,4 @@
1
+ export type NetworkId = bigint | string;
1
2
  export declare const SignalType: {
2
3
  ConnectRequest: string;
3
4
  ConnectResponse: string;
@@ -8,8 +9,9 @@ export declare class SignalStructure {
8
9
  type: string;
9
10
  connectionId: bigint;
10
11
  data: string;
11
- networkId: bigint;
12
- constructor(type: string, connectionId: bigint, data: string, networkId: bigint);
12
+ networkId: NetworkId;
13
+ rpcFrom?: string;
14
+ constructor(type: string, connectionId: bigint, data: string, networkId: NetworkId);
13
15
  toString(): string;
14
- static fromString(message: string, networkId: bigint): SignalStructure;
16
+ static fromString(message: string, networkId: NetworkId): SignalStructure;
15
17
  }
@@ -12,6 +12,7 @@ class SignalStructure {
12
12
  connectionId;
13
13
  data;
14
14
  networkId;
15
+ rpcFrom;
15
16
  constructor(type, connectionId, data, networkId) {
16
17
  this.type = type;
17
18
  this.connectionId = connectionId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bedrock-portal-nethernet",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "A simple server implementation for the Bedrock Edition Nethernet protocol",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",