@pureweb/platform-streaming-agent
Version:
The PureWeb platform streaming agent enables your game to communicate and stream through the PureWeb Platform
297 lines (296 loc) • 15 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SignallingService = void 0;
const platform_sdk_1 = require("@pureweb/platform-sdk");
const Log_1 = __importDefault(require("../../Log"));
const ws_1 = __importDefault(require("ws"));
const bimap_1 = require("./bimap");
const ConnectionStates_1 = require("../../ConnectionStates");
class SignallingService {
constructor(agent, config, platform) {
this.ADDRESS_IN_USE = 'EADDRINUSE';
this.iceCandidateMessageEvents = new Map();
/**
* Notify through agent contributions that stream is available
*/
this.notifyRemoteAgentsStreamConnected = async () => {
if (this.contribution) {
const contributions = await this.agent.getContributions(platform_sdk_1.SignallingTopics.WEBRTC_UNREAL_STREAM, this.agent.id);
for (let i = 0; i < contributions.length; i++) {
if (contributions[i] && contributions[i].id === this.contribution.id) {
Log_1.default.debug('Duplicate contribution is not allowed');
return;
}
}
}
const contribution = await this.agent
.makeContribution({
type: platform_sdk_1.SignallingTopics.WEBRTC_UNREAL_STREAM,
data: JSON.stringify({
launchRequestId: this.agent.serviceCredentials.launchRequestId,
modelId: this.agent.serviceCredentials.modelId,
ueVersion: this.config.ueVersion
})
})
.catch((err) => {
Log_1.default.error('Failed to make contribution: ' + err);
this.connectionStateChange?.(ConnectionStates_1.ConnectionStates.INTERNAL_ERROR);
});
this.contribution = contribution;
};
/**
* Notifies through agent contributions that stream is not available
*/
this.notifyRemoteAgentsStreamDisconnected = async () => {
if (this.contribution) {
await this.agent.removeContribution(this.contribution).catch((err) => {
Log_1.default.error('Failed to remove contribution', err);
});
this.contribution = undefined;
}
};
/**
*
* @param streamerConnection WebSocket to unreal pixelStreaming
*/
this.connect = async (streamerConnection) => {
if (this.streamerConnection) {
return;
}
this.streamerConnection = streamerConnection;
Log_1.default.info('Streamer connected to streaming agent');
this.streamerConnection.onclose = (ev) => {
Log_1.default.info(`<- Streamer connection closed: ${ev.code} - ${ev.reason}`);
this.agentPlayerMap = new bimap_1.Bimap();
};
this.streamerConnection.onmessage = (ev) => {
Log_1.default.debug(`<- Streamer: ${ev.data}`);
try {
const msg = JSON.parse(ev.data.toString());
// As of unreal 4.27 they have changed this datatype to strings and force the clients to handle both.
// parseInt returns parseInt(arg.ToString()) when not a string, so works in both cases.
const playerId = parseInt(msg.playerId);
delete msg.playerId;
const agentId = this.agentPlayerMap.keyForValue(playerId);
msg.topic = platform_sdk_1.SignallingTopics.WEBRTC_UNREAL_STREAM;
if (msg.type === platform_sdk_1.SignallingTypes.answer || msg.type === platform_sdk_1.SignallingTypes.iceCandidate) {
Log_1.default.debug('Sending message to: ' + agentId);
this.agent.sendMessage(platform_sdk_1.SignallingTopics.WEBRTC_UNREAL_STREAM, JSON.stringify({
webrtc_type: msg.type,
message: msg
}), new platform_sdk_1.RemoteAgent(agentId));
}
else if (msg.type == platform_sdk_1.SignallingTypes.disconnectPlayer) {
Log_1.default.debug('Player disconnected: ' + playerId);
}
else {
Log_1.default.debug(`Unsupported message type received from streaming websocket`);
}
}
catch (err) {
Log_1.default.error('Error parsing streamer message: ' + err);
}
};
// Provide ice server details to the streamer
const iceServers = this.agent.serviceCredentials.iceServers;
function getIceServers(initialIceServers) {
const iceServers = initialIceServers.map(function callback(currentValue) {
// Return element for new_array
const newValue = JSON.parse(JSON.stringify(currentValue));
newValue.urls = [newValue.url];
delete newValue.url;
return newValue;
});
return iceServers;
}
const streamerConfig = {
type: 'config',
peerConnectionOptions: {
iceServers: getIceServers(iceServers)
}
};
this.streamerConnection.send(JSON.stringify(streamerConfig));
try {
if (this.platform?.credentials?.agent?.launchRequestId?.length > 0) {
if (this.config.standbyMode === false) {
await this.platform.launchRequestAcknowledgements(platform_sdk_1.LaunchRequestAcknowledgementType.StreamingAgentReady, 'Stream is ready to be consumed');
}
else {
await this.platform.launchRequestAcknowledgements(platform_sdk_1.LaunchRequestAcknowledgementType.StandbyStreamingAgentReady, 'Standby stream is ready to be consumed');
}
}
else {
Log_1.default.info('No associated launch request. Assuming local development workflow');
}
}
catch (err) {
Log_1.default.error('Unable to acknowledge streaming agent ready state: ' + err);
}
//Notify that unreal stream is available
await this.notifyRemoteAgentsStreamConnected();
};
/**
* Starts Signalling service.
* Listens for pixel streaming connection event on streamer websocket server
* Subscribes to agent platform messages that are used as a Signalling service
*/
this.start = async (presenceMonitor) => {
this._presenceMonitor = presenceMonitor;
if (this.config.streamerTimeoutSeconds >= 0) {
setTimeout(() => {
if (!this.streamerConnection) {
if (this.config.standbyMode) {
Log_1.default.info('Standby timeout reached. Streaming agent shutting down');
this.connectionStateChange(ConnectionStates_1.ConnectionStates.STANDBY_TIMEOUT);
}
else {
Log_1.default.info(`Streamer Rendezvous missed.`);
this.connectionStateChange?.(ConnectionStates_1.ConnectionStates.STREAMER_RENDEZVOUS_MISSED);
}
}
else {
Log_1.default.info(`Streamer Rendezvous successful.`);
}
}, this.config.streamerTimeoutSeconds * 1000);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.streamerServer.on('error', (error) => {
Log_1.default.error(`Streamer websocket error ${error}`);
if (error && error.message.includes(this.ADDRESS_IN_USE)) {
Log_1.default.error(`Specified streamer port: ${error.port} is already in use.`);
this.connectionStateChange(ConnectionStates_1.ConnectionStates.STREAMER_PORT_UNAVAILABLE);
}
});
this.streamerServer.on('connection', async (ws, req) => {
if (this.streamerConnection) {
Log_1.default.error('Streamer already connected');
ws.close(1013 /* Try again later */, 'Streamer already connected');
this.connectionStateChange(ConnectionStates_1.ConnectionStates.STREAMER_DUPLICATE_CONNECTION);
return;
}
Log_1.default.info(`Streamer connected: ${req.socket.remoteAddress}`);
await this.connect(ws);
ws.on('close', async (code, reason) => {
Log_1.default.info(`streamer disconnected: ${code} - ${reason}`);
await this.notifyRemoteAgentsStreamDisconnected();
this.streamerConnection = null;
this.connectionStateChange(ConnectionStates_1.ConnectionStates.STREAMER_DISCONNECTED);
});
ws.on('error', async (error) => {
Log_1.default.error(`streamer connection error: ${error}`);
ws.close(1006 /* abnormal closure */, error);
await this.notifyRemoteAgentsStreamDisconnected();
this.streamerConnection = null;
this.connectionStateChange(ConnectionStates_1.ConnectionStates.STREAMER_CONNECTION_ERROR);
});
});
this.agent.subscribeToMessages(platform_sdk_1.MessageTypes.PRIVATE, {
privateMessage: (data) => {
this.processMessage(data);
}
});
};
this.agentPlayerMap = new bimap_1.Bimap();
this.playerId = 100;
this.agent = agent;
this.config = config;
this.platform = platform;
this.streamerServer = new ws_1.default.Server({
port: config.streamerPort,
backlog: 1
});
}
onStateChanged(handler) {
this.connectionStateChange = handler;
}
reconnect() {
Log_1.default.info('Reconnecting streamer to agent platform');
if (this.contribution) {
this.notifyRemoteAgentsStreamConnected();
}
}
stop() {
Log_1.default.info('stop');
this.streamerServer.close();
}
async processMessage(data) {
if (data.type !== platform_sdk_1.SignallingTopics.WEBRTC_UNREAL_STREAM) {
return;
}
try {
const msg = JSON.parse(data.payload);
//Listen to offers and iceCandidates from agents
if (msg.webrtc_type !== platform_sdk_1.SignallingTypes.offer &&
msg.webrtc_type !== platform_sdk_1.SignallingTypes.iceCandidate &&
msg.webrtc_type !== platform_sdk_1.SignallingTypes.webRTCConnectionFailure) {
return;
}
if (msg.webrtc_type === platform_sdk_1.SignallingTypes.webRTCConnectionFailure) {
Log_1.default.error(`WebRTC connection failure ${JSON.stringify(msg.payload)}`);
if (this.platform?.credentials?.agent?.launchRequestId?.length > 0) {
await this.platform.launchRequestAcknowledgements(platform_sdk_1.LaunchRequestAcknowledgementType.WebRTCConnectionFailed, msg.payload);
}
await this.notifyRemoteAgentsStreamDisconnected();
this.streamerConnection = null;
this.connectionStateChange(ConnectionStates_1.ConnectionStates.STREAMER_CONNECTION_ERROR);
return;
}
//If offer send it means that the new agent wants to accept unreal stream
if (msg.webrtc_type === platform_sdk_1.SignallingTypes.offer) {
Log_1.default.info('Adding offer sender: ' + data.senderId);
this.addRemoteAgentToMap(data.senderId);
this._presenceMonitor.monitor(data.senderId, true);
// globally replace sendrecv with recvonly
//msg.payload.sdp = msg.payload.sdp.replace(/a=sendrecv/g, 'a=recvonly');
// Note: the above munge messes with browser sent audio, leave this up to the client
// so that the `useMic` feature can work as intended.
}
const payload = msg.payload;
//Get playerId for specific agent so pixel streaming will find proper socket connection on unreal side
const playerId = this.agentPlayerMap.valueForKey(data.senderId);
if (!playerId) {
let events = this.iceCandidateMessageEvents.get(data.senderId);
if (events === undefined) {
events = [];
}
events.push(data);
this.iceCandidateMessageEvents.set(data.senderId, events);
return;
}
payload.playerId = playerId;
Log_1.default.info(JSON.stringify(payload));
if (payload.webrtc_type === platform_sdk_1.SignallingTypes.offer) {
this.streamerConnection.send(JSON.stringify(payload));
const events = this.iceCandidateMessageEvents.get(data.senderId);
//Process cached iceCandidateEvents
events?.forEach((iceCandidateEvent) => {
this.streamerConnection.send(JSON.stringify(iceCandidateEvent));
});
this.iceCandidateMessageEvents.delete(data.senderId);
}
else {
this.streamerConnection.send(JSON.stringify(payload));
}
if (this.config.standbyMode === true) {
await this.platform.launchRequestAcknowledgements(platform_sdk_1.LaunchRequestAcknowledgementType.StandbyAgentArrived, 'Streamer is ready to be consumed');
}
}
catch (e) {
Log_1.default.error(e);
}
}
/**
* Add remote agent to the map so we can match agents ids with playerIds that are passed to the unreal game pixelStreamer connection
* Note: pixelStreamer expects number hence the map since our agent ids are strings
* @param agentId
*/
addRemoteAgentToMap(agentId) {
if (!this.agentPlayerMap.hasKey(agentId)) {
this.agentPlayerMap.set(agentId, this.playerId++);
}
}
}
exports.SignallingService = SignallingService;