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.
- package/dist/nethernet/server.d.ts +12 -0
- package/dist/nethernet/server.js +88 -5
- package/dist/options.d.ts +3 -11
- package/dist/options.js +3 -2
- package/dist/server.js +1 -1
- package/dist/serverPlayer.js +1 -2
- package/dist/signaling/signal.d.ts +27 -9
- package/dist/signaling/signal.js +301 -34
- package/dist/signaling/struct.d.ts +5 -3
- package/dist/signaling/struct.js +1 -0
- package/package.json +1 -1
|
@@ -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 {};
|
package/dist/nethernet/server.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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;
|
package/dist/serverPlayer.js
CHANGED
|
@@ -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 <
|
|
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:
|
|
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
|
-
|
|
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 {};
|
package/dist/signaling/signal.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
411
|
+
const data = typeof dataString === 'string' ? JSON.parse(dataString) : dataString;
|
|
412
|
+
if (!isRecord(data))
|
|
152
413
|
return servers;
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
157
|
-
const match = url.match(
|
|
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[
|
|
162
|
-
username: server
|
|
163
|
-
password: server
|
|
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:
|
|
12
|
-
|
|
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:
|
|
16
|
+
static fromString(message: string, networkId: NetworkId): SignalStructure;
|
|
15
17
|
}
|
package/dist/signaling/struct.js
CHANGED