@epicgames-ps/lib-pixelstreamingsignalling-ue5.8 0.1.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/.lintstagedrc.mjs +7 -0
- package/.prettierignore +0 -0
- package/CHANGELOG.md +21 -0
- package/README.md +32 -0
- package/dist/cjs/Logger.js +148 -0
- package/dist/cjs/Logger.js.map +1 -0
- package/dist/cjs/LoggingUtils.js +64 -0
- package/dist/cjs/LoggingUtils.js.map +1 -0
- package/dist/cjs/PlayerConnection.js +220 -0
- package/dist/cjs/PlayerConnection.js.map +1 -0
- package/dist/cjs/PlayerRegistry.js +77 -0
- package/dist/cjs/PlayerRegistry.js.map +1 -0
- package/dist/cjs/SFUConnection.js +264 -0
- package/dist/cjs/SFUConnection.js.map +1 -0
- package/dist/cjs/SignallingServer.js +149 -0
- package/dist/cjs/SignallingServer.js.map +1 -0
- package/dist/cjs/StreamerConnection.js +172 -0
- package/dist/cjs/StreamerConnection.js.map +1 -0
- package/dist/cjs/StreamerRegistry.js +149 -0
- package/dist/cjs/StreamerRegistry.js.map +1 -0
- package/dist/cjs/Utils.js +19 -0
- package/dist/cjs/Utils.js.map +1 -0
- package/dist/cjs/WebServer.js +124 -0
- package/dist/cjs/WebServer.js.map +1 -0
- package/dist/cjs/pixelstreamingsignalling.js +28 -0
- package/dist/cjs/pixelstreamingsignalling.js.map +1 -0
- package/dist/esm/Logger.js +141 -0
- package/dist/esm/Logger.js.map +1 -0
- package/dist/esm/LoggingUtils.js +58 -0
- package/dist/esm/LoggingUtils.js.map +1 -0
- package/dist/esm/PlayerConnection.js +183 -0
- package/dist/esm/PlayerConnection.js.map +1 -0
- package/dist/esm/PlayerRegistry.js +73 -0
- package/dist/esm/PlayerRegistry.js.map +1 -0
- package/dist/esm/SFUConnection.js +227 -0
- package/dist/esm/SFUConnection.js.map +1 -0
- package/dist/esm/SignallingServer.js +112 -0
- package/dist/esm/SignallingServer.js.map +1 -0
- package/dist/esm/StreamerConnection.js +135 -0
- package/dist/esm/StreamerConnection.js.map +1 -0
- package/dist/esm/StreamerRegistry.js +145 -0
- package/dist/esm/StreamerRegistry.js.map +1 -0
- package/dist/esm/Utils.js +15 -0
- package/dist/esm/Utils.js.map +1 -0
- package/dist/esm/WebServer.js +117 -0
- package/dist/esm/WebServer.js.map +1 -0
- package/dist/esm/pixelstreamingsignalling.js +12 -0
- package/dist/esm/pixelstreamingsignalling.js.map +1 -0
- package/dist/types/Logger.d.ts +20 -0
- package/dist/types/LoggingUtils.d.ts +44 -0
- package/dist/types/PlayerConnection.d.ts +66 -0
- package/dist/types/PlayerRegistry.d.ts +66 -0
- package/dist/types/SFUConnection.d.ts +85 -0
- package/dist/types/SignallingServer.d.ts +48 -0
- package/dist/types/StreamerConnection.d.ts +61 -0
- package/dist/types/StreamerRegistry.d.ts +96 -0
- package/dist/types/Utils.d.ts +8 -0
- package/dist/types/WebServer.d.ts +42 -0
- package/dist/types/pixelstreamingsignalling.d.ts +9 -0
- package/docs/Logger/README.md +19 -0
- package/docs/Logger/functions/InitLogging.md +26 -0
- package/docs/Logger/interfaces/IConfig.md +41 -0
- package/docs/Logger/variables/Logger.md +15 -0
- package/docs/LoggingUtils/README.md +19 -0
- package/docs/LoggingUtils/functions/createHandlerListener.md +42 -0
- package/docs/LoggingUtils/functions/logForward.md +36 -0
- package/docs/LoggingUtils/functions/logIncoming.md +30 -0
- package/docs/LoggingUtils/functions/logOutgoing.md +30 -0
- package/docs/LoggingUtils/interfaces/IMessageLogger.md +29 -0
- package/docs/LoggingUtils/interfaces/IProtoLogObj.md +57 -0
- package/docs/PlayerConnection/README.md +11 -0
- package/docs/PlayerConnection/classes/PlayerConnection.md +177 -0
- package/docs/PlayerRegistry/README.md +16 -0
- package/docs/PlayerRegistry/classes/PlayerRegistry.md +423 -0
- package/docs/PlayerRegistry/interfaces/IPlayer.md +86 -0
- package/docs/PlayerRegistry/interfaces/IPlayerInfo.md +43 -0
- package/docs/README.md +19 -0
- package/docs/SFUConnection/README.md +11 -0
- package/docs/SFUConnection/classes/SFUConnection.md +554 -0
- package/docs/SignallingServer/README.md +19 -0
- package/docs/SignallingServer/classes/SignallingServer.md +76 -0
- package/docs/SignallingServer/interfaces/IServerConfig.md +92 -0
- package/docs/SignallingServer/type-aliases/ProtocolConfig.md +15 -0
- package/docs/StreamerConnection/README.md +11 -0
- package/docs/StreamerConnection/classes/StreamerConnection.md +507 -0
- package/docs/StreamerRegistry/README.md +16 -0
- package/docs/StreamerRegistry/classes/StreamerRegistry.md +431 -0
- package/docs/StreamerRegistry/interfaces/IStreamer.md +375 -0
- package/docs/StreamerRegistry/interfaces/IStreamerInfo.md +51 -0
- package/docs/Utils/README.md +12 -0
- package/docs/Utils/functions/beautify.md +23 -0
- package/docs/Utils/functions/stringify.md +23 -0
- package/docs/WebServer/README.md +15 -0
- package/docs/WebServer/classes/WebServer.md +50 -0
- package/docs/WebServer/interfaces/IWebServerConfig.md +76 -0
- package/docs/pixelstreamingsignalling/README.md +145 -0
- package/eslint.config.mjs +39 -0
- package/package.json +58 -0
- package/src/Logger.ts +189 -0
- package/src/LoggingUtils.ts +80 -0
- package/src/PlayerConnection.ts +257 -0
- package/src/PlayerRegistry.ts +116 -0
- package/src/SFUConnection.ts +329 -0
- package/src/SignallingServer.ts +214 -0
- package/src/StreamerConnection.ts +194 -0
- package/src/StreamerRegistry.ts +252 -0
- package/src/Utils.ts +16 -0
- package/src/WebServer.ts +213 -0
- package/src/pixelstreamingsignalling.ts +11 -0
- package/tsconfig.cjs.json +8 -0
- package/tsconfig.esm.json +8 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import https from 'https';
|
|
4
|
+
import * as wslib from 'ws';
|
|
5
|
+
import { StreamerConnection } from './StreamerConnection';
|
|
6
|
+
import { PlayerConnection } from './PlayerConnection';
|
|
7
|
+
import { SFUConnection } from './SFUConnection';
|
|
8
|
+
import { Logger } from './Logger';
|
|
9
|
+
import { StreamerRegistry, StreamerIdAuthorizer } from './StreamerRegistry';
|
|
10
|
+
import { PlayerRegistry } from './PlayerRegistry';
|
|
11
|
+
import {
|
|
12
|
+
Messages,
|
|
13
|
+
MessageHelpers,
|
|
14
|
+
SignallingProtocol,
|
|
15
|
+
KeepaliveMonitor
|
|
16
|
+
} from '@epicgames-ps/lib-pixelstreamingcommon-ue5.8';
|
|
17
|
+
import { stringify } from './Utils';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* An interface describing the possible options to pass when creating
|
|
21
|
+
* a new SignallingServer object.
|
|
22
|
+
*/
|
|
23
|
+
export interface IServerConfig {
|
|
24
|
+
// An http server to use for player connections rather than a port. Not needed if playerPort or httpsServer supplied.
|
|
25
|
+
httpServer?: http.Server;
|
|
26
|
+
|
|
27
|
+
// An https server to use for player connections rather than a port. Not needed if playerPort or httpServer supplied.
|
|
28
|
+
httpsServer?: https.Server;
|
|
29
|
+
|
|
30
|
+
// The port to listen on for streamer connections.
|
|
31
|
+
streamerPort: number;
|
|
32
|
+
|
|
33
|
+
// The port to listen on for player connections. Not needed if httpServer or httpsServer supplied.
|
|
34
|
+
playerPort?: number;
|
|
35
|
+
|
|
36
|
+
// The port to listen on for SFU connections. If not supplied SFU connections will be disabled.
|
|
37
|
+
sfuPort?: number;
|
|
38
|
+
|
|
39
|
+
// The peer configuration object to send to peers in the config message when they connect.
|
|
40
|
+
peerOptions: unknown;
|
|
41
|
+
|
|
42
|
+
// Additional websocket options for the streamer listening websocket.
|
|
43
|
+
streamerWsOptions?: wslib.ServerOptions;
|
|
44
|
+
|
|
45
|
+
// Additional websocket options for the player listening websocket.
|
|
46
|
+
playerWsOptions?: wslib.ServerOptions;
|
|
47
|
+
|
|
48
|
+
// Additional websocket options for the SFU listening websocket.
|
|
49
|
+
sfuWsOptions?: wslib.ServerOptions;
|
|
50
|
+
|
|
51
|
+
// Max number of players per streamer.
|
|
52
|
+
maxSubscribers?: number;
|
|
53
|
+
|
|
54
|
+
// Idle timeout in milliseconds after which a player that has stopped responding to keepalive
|
|
55
|
+
// pings is forcibly disconnected. 0 (the default) disables the check.
|
|
56
|
+
playerKeepaliveTimeout?: number;
|
|
57
|
+
|
|
58
|
+
// Optional hook to authorize (or override) the id a streamer registers as when it identifies
|
|
59
|
+
// itself. This is the seam for consumer-supplied anti-squatting / ownership policy; the project
|
|
60
|
+
// ships no authentication of its own. See StreamerIdAuthorizer. When omitted, the default
|
|
61
|
+
// behaviour is unchanged (requested id accepted, numeric suffix appended on collision).
|
|
62
|
+
authorizeStreamerId?: StreamerIdAuthorizer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ProtocolConfig = {
|
|
66
|
+
[key: string]: any;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The main signalling server object.
|
|
71
|
+
* Contains a streamer and player registry and handles setting up of websockets
|
|
72
|
+
* to listen for incoming connections.
|
|
73
|
+
*/
|
|
74
|
+
export class SignallingServer {
|
|
75
|
+
config: IServerConfig;
|
|
76
|
+
protocolConfig: ProtocolConfig;
|
|
77
|
+
streamerRegistry: StreamerRegistry;
|
|
78
|
+
playerRegistry: PlayerRegistry;
|
|
79
|
+
startTime: Date;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Initializes the server object and sets up listening sockets for streamers
|
|
83
|
+
* players and optionally SFU connections.
|
|
84
|
+
* @param config - A collection of options for this server.
|
|
85
|
+
*/
|
|
86
|
+
constructor(config: IServerConfig) {
|
|
87
|
+
Logger.debug('Started SignallingServer with config: %s', stringify(config));
|
|
88
|
+
|
|
89
|
+
this.config = config;
|
|
90
|
+
this.streamerRegistry = new StreamerRegistry(config.authorizeStreamerId);
|
|
91
|
+
this.playerRegistry = new PlayerRegistry();
|
|
92
|
+
this.protocolConfig = {
|
|
93
|
+
protocolVersion: SignallingProtocol.SIGNALLING_VERSION,
|
|
94
|
+
peerConnectionOptions: this.config.peerOptions || {}
|
|
95
|
+
};
|
|
96
|
+
this.startTime = new Date();
|
|
97
|
+
|
|
98
|
+
if (!config.playerPort && !config.httpServer && !config.httpsServer) {
|
|
99
|
+
Logger.error('No player port, http server or https server supplied to SignallingServer.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Streamer connections
|
|
104
|
+
const streamerServer = new wslib.WebSocketServer({
|
|
105
|
+
port: config.streamerPort,
|
|
106
|
+
backlog: 1,
|
|
107
|
+
...config.streamerWsOptions
|
|
108
|
+
});
|
|
109
|
+
streamerServer.on('connection', this.onStreamerConnected.bind(this));
|
|
110
|
+
Logger.info(`Listening for streamer connections on port ${config.streamerPort}`);
|
|
111
|
+
|
|
112
|
+
// Player connections
|
|
113
|
+
const server = config.httpsServer || config.httpServer;
|
|
114
|
+
const playerServer = new wslib.WebSocketServer({
|
|
115
|
+
server: server,
|
|
116
|
+
port: server ? undefined : config.playerPort,
|
|
117
|
+
...config.playerWsOptions
|
|
118
|
+
});
|
|
119
|
+
playerServer.on('connection', this.onPlayerConnected.bind(this));
|
|
120
|
+
if (!config.httpServer && !config.httpsServer) {
|
|
121
|
+
Logger.info(`Listening for player connections on port ${config.playerPort}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Optional SFU connections
|
|
125
|
+
if (config.sfuPort) {
|
|
126
|
+
const sfuServer = new wslib.WebSocketServer({
|
|
127
|
+
port: config.sfuPort,
|
|
128
|
+
backlog: 1,
|
|
129
|
+
...config.sfuWsOptions
|
|
130
|
+
});
|
|
131
|
+
sfuServer.on('connection', this.onSFUConnected.bind(this));
|
|
132
|
+
Logger.info(`Listening for SFU connections on port ${config.sfuPort}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private sendConfigMessage(connection: { sendMessage(msg: Messages.config): void }): void {
|
|
137
|
+
// peer connection options is a general field with all optional fields;
|
|
138
|
+
// it doesnt play nice with mergePartial so we just add it verbatim
|
|
139
|
+
const message: Messages.config = MessageHelpers.createMessage(Messages.config, this.protocolConfig);
|
|
140
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
141
|
+
message.peerConnectionOptions = this.protocolConfig['peerConnectionOptions'];
|
|
142
|
+
connection.sendMessage(message);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private onStreamerConnected(ws: wslib.WebSocket, request: http.IncomingMessage) {
|
|
146
|
+
Logger.info(`New streamer connection: %s`, request.socket.remoteAddress);
|
|
147
|
+
|
|
148
|
+
const newStreamer = new StreamerConnection(this, ws, request.socket.remoteAddress, request);
|
|
149
|
+
newStreamer.maxSubscribers = this.config.maxSubscribers || 0;
|
|
150
|
+
|
|
151
|
+
// add it to the registry and when the transport closes, remove it.
|
|
152
|
+
this.streamerRegistry.add(newStreamer);
|
|
153
|
+
newStreamer.transport.on('close', () => {
|
|
154
|
+
this.streamerRegistry.remove(newStreamer);
|
|
155
|
+
Logger.info(
|
|
156
|
+
`Streamer %s (%s) disconnected.`,
|
|
157
|
+
newStreamer.streamerId,
|
|
158
|
+
request.socket.remoteAddress
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
this.sendConfigMessage(newStreamer);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private onPlayerConnected(ws: wslib.WebSocket, request: http.IncomingMessage) {
|
|
166
|
+
Logger.info(`New player connection: %s (%s)`, request.socket.remoteAddress, request.url);
|
|
167
|
+
|
|
168
|
+
const newPlayer = new PlayerConnection(this, ws, request.socket.remoteAddress, request);
|
|
169
|
+
|
|
170
|
+
// add it to the registry and when the transport closes, remove it
|
|
171
|
+
this.playerRegistry.add(newPlayer);
|
|
172
|
+
newPlayer.transport.on('close', () => {
|
|
173
|
+
this.playerRegistry.remove(newPlayer);
|
|
174
|
+
Logger.info(`Player %s (%s) disconnected.`, newPlayer.playerId, request.socket.remoteAddress);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Optionally monitor the player connection for liveness. A player whose socket dies without
|
|
178
|
+
// a clean close frame (sleeping laptop, dropped Wi-Fi, killed tab) is otherwise only removed
|
|
179
|
+
// once the OS TCP keepalive eventually reaps it, leaving it subscribed in the meantime. When
|
|
180
|
+
// maxSubscribers is set this can hold a slot that no live player is using. We use
|
|
181
|
+
// ws.terminate() rather than a graceful close because a dead peer never completes the close
|
|
182
|
+
// handshake. The monitor stops itself on transport 'close', so no manual teardown is needed.
|
|
183
|
+
const keepaliveTimeout = this.config.playerKeepaliveTimeout || 0;
|
|
184
|
+
if (keepaliveTimeout > 0) {
|
|
185
|
+
const keepalive = new KeepaliveMonitor(newPlayer.protocol, keepaliveTimeout);
|
|
186
|
+
keepalive.onTimeout = () => {
|
|
187
|
+
Logger.info(
|
|
188
|
+
`Player %s (%s) failed keepalive - terminating dead connection.`,
|
|
189
|
+
newPlayer.playerId,
|
|
190
|
+
request.socket.remoteAddress
|
|
191
|
+
);
|
|
192
|
+
ws.terminate();
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.sendConfigMessage(newPlayer);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private onSFUConnected(ws: wslib.WebSocket, request: http.IncomingMessage) {
|
|
200
|
+
Logger.info(`New SFU connection: %s`, request.socket.remoteAddress);
|
|
201
|
+
const newSFU = new SFUConnection(this, ws, request.socket.remoteAddress, request);
|
|
202
|
+
|
|
203
|
+
// SFU acts as both a streamer and player
|
|
204
|
+
this.streamerRegistry.add(newSFU);
|
|
205
|
+
this.playerRegistry.add(newSFU);
|
|
206
|
+
newSFU.transport.on('close', () => {
|
|
207
|
+
this.streamerRegistry.remove(newSFU);
|
|
208
|
+
this.playerRegistry.remove(newSFU);
|
|
209
|
+
Logger.info(`SFU %s (%s) disconnected.`, newSFU.streamerId, request.socket.remoteAddress);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
this.sendConfigMessage(newSFU);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
2
|
+
import type { IncomingMessage } from 'http';
|
|
3
|
+
import WebSocket from 'ws';
|
|
4
|
+
import {
|
|
5
|
+
ITransport,
|
|
6
|
+
SignallingProtocol,
|
|
7
|
+
WebSocketTransportNJS,
|
|
8
|
+
BaseMessage,
|
|
9
|
+
Messages,
|
|
10
|
+
EventEmitter,
|
|
11
|
+
MessageHelpers
|
|
12
|
+
} from '@epicgames-ps/lib-pixelstreamingcommon-ue5.8';
|
|
13
|
+
import { IStreamer, IStreamerInfo } from './StreamerRegistry';
|
|
14
|
+
import { stringify } from './Utils';
|
|
15
|
+
import { Logger } from './Logger';
|
|
16
|
+
import * as LogUtils from './LoggingUtils';
|
|
17
|
+
import { SignallingServer } from './SignallingServer';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A connection between the signalling server and a streamer connection.
|
|
21
|
+
* This is where messages expected to be handled by the streamer come in
|
|
22
|
+
* and where messages are sent to the streamer.
|
|
23
|
+
*
|
|
24
|
+
* Interesting internals:
|
|
25
|
+
* streamerId: The unique id string of this streamer.
|
|
26
|
+
* transport: The ITransport where transport events can be subscribed to
|
|
27
|
+
* protocol: The SignallingProtocol where signalling messages can be
|
|
28
|
+
* subscribed to.
|
|
29
|
+
* streaming: True when the streamer is ready to accept subscriptions.
|
|
30
|
+
*/
|
|
31
|
+
export class StreamerConnection extends EventEmitter implements IStreamer, LogUtils.IMessageLogger {
|
|
32
|
+
// The streamer id related to this SFU connection.
|
|
33
|
+
streamerId: string;
|
|
34
|
+
// The websocket transport used by this connection.
|
|
35
|
+
transport: ITransport;
|
|
36
|
+
// The protocol abstraction on this connection. Used for sending/receiving signalling messages.
|
|
37
|
+
protocol: SignallingProtocol;
|
|
38
|
+
// True when this streamer is ready to be subscribed to.
|
|
39
|
+
streaming: boolean;
|
|
40
|
+
// A descriptive string describing the remote address of this connection.
|
|
41
|
+
remoteAddress?: string;
|
|
42
|
+
// The HTTP upgrade request that opened this connection, if available.
|
|
43
|
+
request?: IncomingMessage;
|
|
44
|
+
// The max number of subscribed players at a time.
|
|
45
|
+
maxSubscribers: number;
|
|
46
|
+
// A list of all the current subscribed players.
|
|
47
|
+
subscribers: Set<string>;
|
|
48
|
+
|
|
49
|
+
private server: SignallingServer;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Initializes a new connection with given and sane values. Adds listeners for the
|
|
53
|
+
* websocket close and error and will emit a disconnected event when disconneted.
|
|
54
|
+
* @param server - The signalling server object that spawned this streamer.
|
|
55
|
+
* @param ws - The websocket coupled to this streamer connection.
|
|
56
|
+
* @param remoteAddress - The remote address of this connection. Only used as display.
|
|
57
|
+
* @param request - The HTTP upgrade request that opened this connection, if available.
|
|
58
|
+
*/
|
|
59
|
+
constructor(server: SignallingServer, ws: WebSocket, remoteAddress?: string, request?: IncomingMessage) {
|
|
60
|
+
super();
|
|
61
|
+
|
|
62
|
+
this.server = server;
|
|
63
|
+
this.streamerId = '';
|
|
64
|
+
this.transport = new WebSocketTransportNJS(ws);
|
|
65
|
+
this.protocol = new SignallingProtocol(this.transport);
|
|
66
|
+
this.streaming = false;
|
|
67
|
+
this.remoteAddress = remoteAddress;
|
|
68
|
+
this.request = request;
|
|
69
|
+
this.maxSubscribers = 0;
|
|
70
|
+
this.subscribers = new Set();
|
|
71
|
+
|
|
72
|
+
this.transport.on('error', this.onTransportError.bind(this));
|
|
73
|
+
this.transport.on('close', this.onTransportClose.bind(this));
|
|
74
|
+
|
|
75
|
+
this.registerMessageHandlers();
|
|
76
|
+
|
|
77
|
+
this.protocol.on('unhandled', (message: BaseMessage) => {
|
|
78
|
+
Logger.warn(`Unhandled streamer protocol message: ${JSON.stringify(message)}`);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns an identifier that is displayed in logs.
|
|
84
|
+
* @returns A string describing this connection.
|
|
85
|
+
*/
|
|
86
|
+
getReadableIdentifier(): string {
|
|
87
|
+
return this.streamerId;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Sends a signalling message to the player.
|
|
92
|
+
* @param message - The message to send.
|
|
93
|
+
*/
|
|
94
|
+
sendMessage(message: BaseMessage): void {
|
|
95
|
+
LogUtils.logOutgoing(this, message);
|
|
96
|
+
this.protocol.sendMessage(message);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Returns a descriptive object for the REST API inspection operations.
|
|
101
|
+
* @returns An IStreamerInfo object containing viewable information about this connection.
|
|
102
|
+
*/
|
|
103
|
+
getStreamerInfo(): IStreamerInfo {
|
|
104
|
+
return {
|
|
105
|
+
streamerId: this.streamerId,
|
|
106
|
+
type: 'Streamer',
|
|
107
|
+
streaming: this.streaming,
|
|
108
|
+
remoteAddress: this.remoteAddress,
|
|
109
|
+
subscribers: this.server.playerRegistry
|
|
110
|
+
.listPlayers()
|
|
111
|
+
.filter((player) => player.subscribedStreamer == this)
|
|
112
|
+
.map((player) => player.getPlayerInfo())
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private registerMessageHandlers(): void {
|
|
117
|
+
/* eslint-disable @typescript-eslint/unbound-method */
|
|
118
|
+
this.protocol.on(
|
|
119
|
+
Messages.endpointId.typeName,
|
|
120
|
+
LogUtils.createHandlerListener(this, this.onEndpointId)
|
|
121
|
+
);
|
|
122
|
+
this.protocol.on(
|
|
123
|
+
Messages.disconnectPlayer.typeName,
|
|
124
|
+
LogUtils.createHandlerListener(this, this.onDisconnectPlayerRequest)
|
|
125
|
+
);
|
|
126
|
+
this.protocol.on(
|
|
127
|
+
Messages.layerPreference.typeName,
|
|
128
|
+
LogUtils.createHandlerListener(this, this.onLayerPreference)
|
|
129
|
+
);
|
|
130
|
+
this.protocol.on(Messages.ping.typeName, LogUtils.createHandlerListener(this, this.onPingMessage));
|
|
131
|
+
/* eslint-enable @typescript-eslint/unbound-method */
|
|
132
|
+
|
|
133
|
+
this.protocol.on(Messages.offer.typeName, this.forwardMessage.bind(this));
|
|
134
|
+
this.protocol.on(Messages.answer.typeName, this.forwardMessage.bind(this));
|
|
135
|
+
this.protocol.on(Messages.iceCandidate.typeName, this.forwardMessage.bind(this));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private forwardMessage(message: BaseMessage): void {
|
|
139
|
+
if (!message.playerId) {
|
|
140
|
+
Logger.warn(`No playerId specified, cannot forward message: ${stringify(message)}`);
|
|
141
|
+
} else if (!this.subscribers.has(message.playerId)) {
|
|
142
|
+
// Only forward to players that are actually subscribed to this streamer. Without this
|
|
143
|
+
// check a streamer could target any player on the server (resolved via the global player
|
|
144
|
+
// registry), allowing cross-tenant signalling on a shared signalling server.
|
|
145
|
+
Logger.warn(
|
|
146
|
+
`Streamer ${this.streamerId} tried to forward a message to player ${message.playerId} which is not subscribed to it. Ignoring.`
|
|
147
|
+
);
|
|
148
|
+
} else {
|
|
149
|
+
const player = this.server.playerRegistry.get(message.playerId);
|
|
150
|
+
if (player) {
|
|
151
|
+
LogUtils.logForward(this, player, message);
|
|
152
|
+
player.protocol.sendMessage(message);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private onTransportError(error: ErrorEvent): void {
|
|
158
|
+
Logger.error(`Streamer (${this.streamerId}) transport error ${error.message}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private onTransportClose(): void {
|
|
162
|
+
Logger.info(`Streamer '${this.streamerId}' - websocket disconnected.`);
|
|
163
|
+
this.emit('disconnect');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private onEndpointId(_message: Messages.endpointId): void {
|
|
167
|
+
this.streaming = true; // we're ready to stream when we id ourselves
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private onDisconnectPlayerRequest(message: Messages.disconnectPlayer): void {
|
|
171
|
+
if (message.playerId) {
|
|
172
|
+
if (!this.subscribers.has(message.playerId)) {
|
|
173
|
+
// A streamer may only disconnect its own subscribed players. Otherwise any streamer
|
|
174
|
+
// could forcibly close arbitrary player connections on a shared signalling server.
|
|
175
|
+
Logger.warn(
|
|
176
|
+
`Streamer ${this.streamerId} tried to disconnect player ${message.playerId} which is not subscribed to it. Ignoring.`
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const player = this.server.playerRegistry.get(message.playerId);
|
|
181
|
+
if (player) {
|
|
182
|
+
player.protocol.disconnect(1011, message.reason);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private onLayerPreference(message: Messages.layerPreference): void {
|
|
188
|
+
this.emit('layer_preference', message);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private onPingMessage(message: Messages.ping): void {
|
|
192
|
+
this.sendMessage(MessageHelpers.createMessage(Messages.pong, { time: message.time }));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
2
|
+
import {
|
|
3
|
+
ITransport,
|
|
4
|
+
SignallingProtocol,
|
|
5
|
+
Messages,
|
|
6
|
+
MessageHelpers,
|
|
7
|
+
BaseMessage,
|
|
8
|
+
EventEmitter
|
|
9
|
+
} from '@epicgames-ps/lib-pixelstreamingcommon-ue5.8';
|
|
10
|
+
import type { IncomingMessage } from 'http';
|
|
11
|
+
import { Logger } from './Logger';
|
|
12
|
+
import { IMessageLogger } from './LoggingUtils';
|
|
13
|
+
import { IPlayerInfo } from './PlayerRegistry';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An interface that describes a streamer that can be added to the
|
|
17
|
+
* streamer registry.
|
|
18
|
+
*/
|
|
19
|
+
export interface IStreamer extends EventEmitter, IMessageLogger {
|
|
20
|
+
streamerId: string;
|
|
21
|
+
transport: ITransport;
|
|
22
|
+
protocol: SignallingProtocol;
|
|
23
|
+
streaming: boolean;
|
|
24
|
+
maxSubscribers: number;
|
|
25
|
+
subscribers: Set<string>;
|
|
26
|
+
// The HTTP upgrade request that opened this connection, if available. The signalling server
|
|
27
|
+
// itself does not read this, but it lets a consumer-supplied verifyClient (or other front door)
|
|
28
|
+
// attach an authenticated identity to the request and recover it here for authorization
|
|
29
|
+
// decisions. The project intentionally ships no authentication of its own.
|
|
30
|
+
request?: IncomingMessage;
|
|
31
|
+
|
|
32
|
+
sendMessage(message: BaseMessage): void;
|
|
33
|
+
getStreamerInfo(): IStreamerInfo;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The details of a streamer's request to be known by a given id, passed to a
|
|
38
|
+
* {@link StreamerIdAuthorizer} so a consumer can decide whether to allow it.
|
|
39
|
+
*/
|
|
40
|
+
export interface IStreamerIdAuthRequest {
|
|
41
|
+
// The streamer connection requesting the id. Use `streamer.request` to recover any identity a
|
|
42
|
+
// consumer attached at connection time.
|
|
43
|
+
streamer: IStreamer;
|
|
44
|
+
// The raw id the streamer asked to be known by (the value it sent in its endpointId message).
|
|
45
|
+
requestedId: string;
|
|
46
|
+
// The unique id the registry would assign by default (the requested id, with a numeric suffix
|
|
47
|
+
// appended if it collided with an existing streamer).
|
|
48
|
+
sanitizedId: string;
|
|
49
|
+
// True when the requested id was already taken and so differs from the id the registry would
|
|
50
|
+
// otherwise commit. This is the condition an id-squatting attacker relies on.
|
|
51
|
+
collided: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* An optional consumer-supplied hook that decides the id a streamer is allowed to register as.
|
|
56
|
+
* Return the id to commit (the sanitized id to accept the default, or any other unique id to
|
|
57
|
+
* override, e.g. to namespace per tenant), or null to reject the streamer and disconnect it.
|
|
58
|
+
* This is the seam for implementing anti-squatting / ownership policy without the project
|
|
59
|
+
* providing an authentication scheme.
|
|
60
|
+
*/
|
|
61
|
+
export type StreamerIdAuthorizer = (request: IStreamerIdAuthRequest) => string | null;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Used by the API to describe a streamer.
|
|
65
|
+
*/
|
|
66
|
+
export interface IStreamerInfo {
|
|
67
|
+
streamerId: string;
|
|
68
|
+
type: string;
|
|
69
|
+
streaming: boolean;
|
|
70
|
+
remoteAddress: string | undefined;
|
|
71
|
+
subscribers: IPlayerInfo[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Handles all the streamer connections of a signalling server and
|
|
76
|
+
* can be used to lookup connections by id etc.
|
|
77
|
+
* Fires events when streamers are added or removed.
|
|
78
|
+
* Events:
|
|
79
|
+
* 'added': (playerId: string) Player was added.
|
|
80
|
+
* 'removed': (playerId: string) Player was removed.
|
|
81
|
+
*/
|
|
82
|
+
export class StreamerRegistry extends EventEmitter {
|
|
83
|
+
streamers: IStreamer[];
|
|
84
|
+
defaultStreamerIdPrefix: string = 'UnknownStreamer';
|
|
85
|
+
// Optional consumer hook to authorize/override the id a streamer registers as. See
|
|
86
|
+
// {@link StreamerIdAuthorizer}. When unset the registry keeps its default behaviour of
|
|
87
|
+
// accepting the requested id and appending a numeric suffix on collision.
|
|
88
|
+
authorizeStreamerId?: StreamerIdAuthorizer;
|
|
89
|
+
|
|
90
|
+
constructor(authorizeStreamerId?: StreamerIdAuthorizer) {
|
|
91
|
+
super();
|
|
92
|
+
this.streamers = [];
|
|
93
|
+
this.authorizeStreamerId = authorizeStreamerId;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Adds a streamer to the registry. If the streamer already has an id
|
|
98
|
+
* it will be sanitized (checked against existing ids and altered if
|
|
99
|
+
* there are collisions), or if it has no id it will be assigned a
|
|
100
|
+
* default unique id.
|
|
101
|
+
* @returns True if the add was successful.
|
|
102
|
+
*/
|
|
103
|
+
add(streamer: IStreamer): boolean {
|
|
104
|
+
streamer.streamerId = this.sanitizeStreamerId(streamer.streamerId);
|
|
105
|
+
|
|
106
|
+
if (this.find(streamer.streamerId)) {
|
|
107
|
+
Logger.error(
|
|
108
|
+
`StreamerRegistry: Tried to register streamer ${streamer.streamerId} but that id already exists.`
|
|
109
|
+
);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.streamers.push(streamer);
|
|
114
|
+
|
|
115
|
+
// request that the new streamer id itself.
|
|
116
|
+
streamer.protocol.on(Messages.endpointId.typeName, this.onEndpointId.bind(this, streamer));
|
|
117
|
+
streamer.sendMessage(MessageHelpers.createMessage(Messages.identify));
|
|
118
|
+
|
|
119
|
+
this.emit('added', streamer.streamerId);
|
|
120
|
+
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Removes a streamer from the registry. If the streamer isn't found
|
|
126
|
+
* it does nothing.
|
|
127
|
+
* @returns True if the streamer was removed.
|
|
128
|
+
*/
|
|
129
|
+
remove(streamer: IStreamer): boolean {
|
|
130
|
+
const index = this.streamers.indexOf(streamer);
|
|
131
|
+
if (index === -1) {
|
|
132
|
+
Logger.debug(
|
|
133
|
+
`StreamerRegistry: Tried to remove streamer ${streamer.streamerId} but it doesn't exist`
|
|
134
|
+
);
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
this.streamers.splice(index, 1);
|
|
138
|
+
this.emit('removed', streamer.streamerId);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Attempts to find the given streamer id in the registry.
|
|
144
|
+
*/
|
|
145
|
+
find(streamerId: string): IStreamer | undefined {
|
|
146
|
+
return this.streamers.find((streamer) => streamer.streamerId === streamerId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Used by players who haven't subscribed but try to send a message.
|
|
151
|
+
* This is to cover legacy connections that do not know how to subscribe.
|
|
152
|
+
* The player will be assigned the first streamer in the list.
|
|
153
|
+
* @returns The first streamerId in the registry or null if there are none.
|
|
154
|
+
*/
|
|
155
|
+
getFirstStreamerId(): string | null {
|
|
156
|
+
if (this.empty()) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
return this.streamers[0].streamerId;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Returns true when the registry is empty.
|
|
164
|
+
*/
|
|
165
|
+
empty(): boolean {
|
|
166
|
+
return this.streamers.length === 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Returns the total number of connected streamers.
|
|
171
|
+
*/
|
|
172
|
+
count(): number {
|
|
173
|
+
return this.streamers.length;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private onEndpointId(streamer: IStreamer, message: Messages.endpointId): void {
|
|
177
|
+
const oldId = streamer.streamerId;
|
|
178
|
+
|
|
179
|
+
// id might conflict or be invalid so here we sanitize it
|
|
180
|
+
const sanitizedId = this.sanitizeStreamerId(message.id);
|
|
181
|
+
|
|
182
|
+
let committedId: string | null = sanitizedId;
|
|
183
|
+
if (this.authorizeStreamerId) {
|
|
184
|
+
// Let the consumer accept the default id, override it (e.g. namespace per tenant), or
|
|
185
|
+
// reject the registration entirely. This is where anti-squatting policy lives.
|
|
186
|
+
committedId = this.authorizeStreamerId({
|
|
187
|
+
streamer,
|
|
188
|
+
requestedId: message.id,
|
|
189
|
+
sanitizedId,
|
|
190
|
+
collided: !!message.id && sanitizedId !== message.id
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (committedId === null) {
|
|
194
|
+
Logger.warn(
|
|
195
|
+
`StreamerRegistry: streamer id '${message.id}' (was ${oldId}) rejected by authorizer. Disconnecting.`
|
|
196
|
+
);
|
|
197
|
+
this.remove(streamer);
|
|
198
|
+
streamer.protocol.disconnect(1008, 'streamer id not authorized');
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// An override must remain unique or it would corrupt id-based lookups. If the consumer
|
|
203
|
+
// hands back an id already held by a different streamer we reject rather than clobber.
|
|
204
|
+
const existing = this.find(committedId);
|
|
205
|
+
if (existing && existing !== streamer) {
|
|
206
|
+
Logger.error(
|
|
207
|
+
`StreamerRegistry: authorizer returned id '${committedId}' which is already in use. Disconnecting.`
|
|
208
|
+
);
|
|
209
|
+
this.remove(streamer);
|
|
210
|
+
streamer.protocol.disconnect(1008, 'streamer id not authorized');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
streamer.streamerId = committedId;
|
|
216
|
+
|
|
217
|
+
Logger.debug(`StreamerRegistry: Streamer id change. ${oldId} -> ${streamer.streamerId}`);
|
|
218
|
+
streamer.emit('id_changed', streamer.streamerId);
|
|
219
|
+
|
|
220
|
+
// because we might have sanitized the id, we confirm the id back to the streamer
|
|
221
|
+
streamer.sendMessage(
|
|
222
|
+
MessageHelpers.createMessage(Messages.endpointIdConfirm, { committedId: streamer.streamerId })
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private sanitizeStreamerId(id: string): string {
|
|
227
|
+
// create a default id if none supplied
|
|
228
|
+
if (!id) {
|
|
229
|
+
id = this.defaultStreamerIdPrefix;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// search for existing streamerId and optionally append a numeric value
|
|
233
|
+
let maxPostfix = -1;
|
|
234
|
+
for (const streamer of this.streamers) {
|
|
235
|
+
const idMatchRegex = /^(.*?)(\d*)$/;
|
|
236
|
+
|
|
237
|
+
const [, baseId, postfix] = streamer.streamerId.match(idMatchRegex)!;
|
|
238
|
+
// if the id is numeric then base id will be empty and we need to compare with the postfix
|
|
239
|
+
if ((baseId !== '' && baseId !== id) || (baseId === '' && postfix !== id)) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const numPostfix = Number(postfix);
|
|
243
|
+
if (numPostfix > maxPostfix) {
|
|
244
|
+
maxPostfix = numPostfix;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (maxPostfix >= 0) {
|
|
248
|
+
return id + (maxPostfix + 1);
|
|
249
|
+
}
|
|
250
|
+
return id;
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/Utils.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
2
|
+
import { jsonc } from 'jsonc';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cirular reference safe version of JSON.stringify
|
|
6
|
+
*/
|
|
7
|
+
export function stringify(obj: any): string {
|
|
8
|
+
return jsonc.stringify(obj);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Circular reference save version of JSON.stringify with extra formatting.
|
|
13
|
+
*/
|
|
14
|
+
export function beautify(obj: any): string {
|
|
15
|
+
return jsonc.stringify(obj, undefined, '\t');
|
|
16
|
+
}
|