@epicgames-ps/lib-pixelstreamingsignalling-ue5.8 0.1.0 → 0.2.1
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/CHANGELOG.md +21 -0
- package/dist/cjs/Logger.js +23 -9
- package/dist/cjs/Logger.js.map +1 -1
- package/dist/cjs/PlayerConnection.js +8 -0
- package/dist/cjs/PlayerConnection.js.map +1 -1
- package/dist/cjs/SFUConnection.js +5 -0
- package/dist/cjs/SFUConnection.js.map +1 -1
- package/dist/cjs/SignallingServer.js +39 -10
- package/dist/cjs/SignallingServer.js.map +1 -1
- package/dist/cjs/StreamerConnection.js +6 -1
- package/dist/cjs/StreamerConnection.js.map +1 -1
- package/dist/cjs/StreamerRegistry.js +6 -3
- package/dist/cjs/StreamerRegistry.js.map +1 -1
- package/dist/cjs/Utils.js +25 -0
- package/dist/cjs/Utils.js.map +1 -1
- package/dist/esm/Logger.js +24 -10
- package/dist/esm/Logger.js.map +1 -1
- package/dist/esm/PlayerConnection.js +8 -0
- package/dist/esm/PlayerConnection.js.map +1 -1
- package/dist/esm/SFUConnection.js +5 -0
- package/dist/esm/SFUConnection.js.map +1 -1
- package/dist/esm/SignallingServer.js +38 -9
- package/dist/esm/SignallingServer.js.map +1 -1
- package/dist/esm/StreamerConnection.js +6 -1
- package/dist/esm/StreamerConnection.js.map +1 -1
- package/dist/esm/StreamerRegistry.js +6 -3
- package/dist/esm/StreamerRegistry.js.map +1 -1
- package/dist/esm/Utils.js +22 -0
- package/dist/esm/Utils.js.map +1 -1
- package/dist/types/Logger.d.ts +2 -0
- package/dist/types/SignallingServer.d.ts +33 -0
- package/dist/types/Utils.d.ts +14 -0
- package/eslint.config.mjs +1 -1
- package/jest.config.js +14 -0
- package/package.json +17 -14
- package/src/Logger.test.ts +109 -0
- package/src/Logger.ts +30 -9
- package/src/PlayerConnection.ts +12 -2
- package/src/SFUConnection.test.ts +45 -0
- package/src/SFUConnection.ts +5 -0
- package/src/SignallingServer.maxPayload.test.ts +69 -0
- package/src/SignallingServer.test.ts +63 -0
- package/src/SignallingServer.ts +82 -6
- package/src/StreamerConnection.test.ts +48 -0
- package/src/StreamerConnection.ts +7 -1
- package/src/StreamerRegistry.test.ts +27 -0
- package/src/StreamerRegistry.ts +7 -3
- package/src/Utils.test.ts +31 -0
- package/src/Utils.ts +25 -0
- package/tsconfig.jest.json +9 -0
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { IConfig, InitLogging, Logger } from './Logger';
|
|
6
|
+
import { MAX_LOGGED_LENGTH } from './Utils';
|
|
7
|
+
|
|
8
|
+
type Transport = (typeof Logger.transports)[number];
|
|
9
|
+
|
|
10
|
+
const LEVEL = Symbol.for('level');
|
|
11
|
+
const MESSAGE = Symbol.for('message');
|
|
12
|
+
|
|
13
|
+
// Runs a log call through a transport's format and returns the line it would write. Formatting does no
|
|
14
|
+
// I/O, so this reads exactly what the logger produces without waiting on the file.
|
|
15
|
+
function lineFor(transport: Transport, message: unknown): string {
|
|
16
|
+
const info = transport.format?.transform({ level: 'info', message, [LEVEL]: 'info' });
|
|
17
|
+
if (!info || info === true) {
|
|
18
|
+
throw new Error('the format dropped the line');
|
|
19
|
+
}
|
|
20
|
+
return info[MESSAGE] as string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// InitLogging opens the day's file, so every logger a test creates is closed before its folder is removed.
|
|
24
|
+
async function withLogger(config: IConfig, check: (console: Transport, file: Transport) => void) {
|
|
25
|
+
InitLogging(config);
|
|
26
|
+
const [consoleTransport, fileTransport] = Logger.transports;
|
|
27
|
+
try {
|
|
28
|
+
check(consoleTransport, fileTransport);
|
|
29
|
+
} finally {
|
|
30
|
+
await new Promise((resolve) => {
|
|
31
|
+
fileTransport.once('finish', resolve);
|
|
32
|
+
fileTransport.close?.();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('InitLogging', () => {
|
|
38
|
+
let logDir: string;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
logDir = fs.mkdtempSync(path.join(os.tmpdir(), 'signalling-log-'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
fs.rmSync(logDir, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('refuses a file size that the file transport would take as no limit', () => {
|
|
49
|
+
expect(() => InitLogging({ logDir, logMaxSize: '100mb' })).toThrow('logMaxSize');
|
|
50
|
+
expect(() => InitLogging({ logDir, logMaxSize: '0m' })).toThrow('logMaxSize');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('refuses a file count that the file transport would take as no limit', () => {
|
|
54
|
+
expect(() => InitLogging({ logDir, logMaxFiles: 0 })).toThrow('logMaxFiles');
|
|
55
|
+
expect(() => InitLogging({ logDir, logMaxFiles: NaN })).toThrow('logMaxFiles');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('limits the size of each log file and how many are kept', async () => {
|
|
59
|
+
await withLogger({ logDir }, (_console, file) => {
|
|
60
|
+
const { options } = file as unknown as { options: { maxSize: unknown; maxFiles: unknown } };
|
|
61
|
+
expect(options.maxSize).toBe('100m');
|
|
62
|
+
expect(options.maxFiles).toBe(14);
|
|
63
|
+
});
|
|
64
|
+
await withLogger({ logDir, logMaxSize: '5m', logMaxFiles: 3 }, (_console, file) => {
|
|
65
|
+
const { options } = file as unknown as { options: { maxSize: unknown; maxFiles: unknown } };
|
|
66
|
+
expect(options.maxSize).toBe('5m');
|
|
67
|
+
expect(options.maxFiles).toBe(3);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('cuts long text on the console and in the file', async () => {
|
|
72
|
+
const text = 'x'.repeat(100000);
|
|
73
|
+
await withLogger({ logDir }, (console, file) => {
|
|
74
|
+
const consoleLine = lineFor(console, text);
|
|
75
|
+
expect(consoleLine.endsWith('... (100000 characters)')).toBe(true);
|
|
76
|
+
expect(consoleLine.length).toBeLessThan(MAX_LOGGED_LENGTH + 100);
|
|
77
|
+
|
|
78
|
+
const fileLine = lineFor(file, text);
|
|
79
|
+
expect(
|
|
80
|
+
(JSON.parse(fileLine) as { message: string }).message.endsWith('... (100000 characters)')
|
|
81
|
+
).toBe(true);
|
|
82
|
+
expect(fileLine.length).toBeLessThan(MAX_LOGGED_LENGTH + 200);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('writes a protocol message to the file whole, or as cut JSON text when it is too long', async () => {
|
|
87
|
+
const logObj = (protoMessage: object) => ({
|
|
88
|
+
event: 'proto_message',
|
|
89
|
+
direction: 'incoming',
|
|
90
|
+
receiver: 'Player0',
|
|
91
|
+
protoMessage
|
|
92
|
+
});
|
|
93
|
+
await withLogger({ logDir }, (_console, file) => {
|
|
94
|
+
const short = JSON.parse(lineFor(file, logObj({ type: 'ping', time: 1 }))) as {
|
|
95
|
+
protoMessage: unknown;
|
|
96
|
+
};
|
|
97
|
+
expect(short.protoMessage).toEqual({ type: 'ping', time: 1 });
|
|
98
|
+
|
|
99
|
+
const long = { type: 'ping', pad: 'a'.repeat(100000) };
|
|
100
|
+
const longLine = lineFor(file, logObj(long));
|
|
101
|
+
const parsed = JSON.parse(longLine) as { protoMessage: string };
|
|
102
|
+
expect(parsed.protoMessage.startsWith('{"type":"ping"')).toBe(true);
|
|
103
|
+
expect(parsed.protoMessage.endsWith(`... (${JSON.stringify(long).length} characters)`)).toBe(
|
|
104
|
+
true
|
|
105
|
+
);
|
|
106
|
+
expect(longLine.length).toBeLessThan(MAX_LOGGED_LENGTH + 300);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/Logger.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
2
|
-
import { stringify, beautify } from './Utils';
|
|
2
|
+
import { stringify, beautify, limitLogText, limitLoggedMessage } from './Utils';
|
|
3
3
|
import { IProtoLogObj } from './LoggingUtils';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import winston from 'winston';
|
|
@@ -30,6 +30,12 @@ export interface IConfig {
|
|
|
30
30
|
|
|
31
31
|
// the minimum log level for file messages
|
|
32
32
|
logLevelFile?: string;
|
|
33
|
+
|
|
34
|
+
// the size at which a log file is closed and a new one started, as a whole number followed by k, m or g
|
|
35
|
+
logMaxSize?: string;
|
|
36
|
+
|
|
37
|
+
// the number of log files to keep. The oldest is deleted when a new one is started.
|
|
38
|
+
logMaxFiles?: number;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
/**
|
|
@@ -70,10 +76,23 @@ export function InitLogging(config: IConfig): void {
|
|
|
70
76
|
const logDir = config.logDir || 'logs';
|
|
71
77
|
const logLevelConsole = config.logLevelConsole || 'info';
|
|
72
78
|
const logLevelFile = config.logLevelFile || 'info';
|
|
79
|
+
const logMaxSize = config.logMaxSize || '100m';
|
|
80
|
+
const logMaxFiles = config.logMaxFiles ?? 14;
|
|
73
81
|
logMessagesToConsole = config.logMessagesToConsole || 'none';
|
|
74
82
|
|
|
83
|
+
// winston-daily-rotate-file takes a size it cannot parse, or a count of 0, as no limit at all.
|
|
84
|
+
if (!/^[1-9]\d*[kmg]$/i.test(logMaxSize)) {
|
|
85
|
+
throw new Error(`logMaxSize must be a whole number followed by k, m or g, not "${logMaxSize}".`);
|
|
86
|
+
}
|
|
87
|
+
if (!Number.isInteger(logMaxFiles) || logMaxFiles < 1) {
|
|
88
|
+
throw new Error(`logMaxFiles must be a whole number of at least 1, not "${logMaxFiles}".`);
|
|
89
|
+
}
|
|
90
|
+
|
|
75
91
|
Logger = winston.createLogger({
|
|
76
|
-
transports: [
|
|
92
|
+
transports: [
|
|
93
|
+
createConsoleTransport(logLevelConsole),
|
|
94
|
+
createFileTransport(logDir, logLevelFile, logMaxSize, logMaxFiles)
|
|
95
|
+
]
|
|
77
96
|
});
|
|
78
97
|
|
|
79
98
|
const commonLogger = new CommonLogger(Logger);
|
|
@@ -118,7 +137,7 @@ function createConsoleFormat() {
|
|
|
118
137
|
return printf((logObj: TransformableInfo) => {
|
|
119
138
|
const prefix = `[${String(logObj['timestamp'])}] ${logObj.level}: `;
|
|
120
139
|
if (typeof logObj.message === 'string') {
|
|
121
|
-
return prefix + logObj.message;
|
|
140
|
+
return prefix + limitLogText(logObj.message);
|
|
122
141
|
} else if (isLogObject(logObj.message)) {
|
|
123
142
|
const { direction, receiver, sender, target, protoMessage } = logObj.message;
|
|
124
143
|
switch (direction) {
|
|
@@ -139,9 +158,9 @@ function createConsoleFormat() {
|
|
|
139
158
|
function formatMessageForConsole(message: BaseMessage) {
|
|
140
159
|
switch (logMessagesToConsole) {
|
|
141
160
|
case 'verbose':
|
|
142
|
-
return stringify(message);
|
|
161
|
+
return limitLogText(stringify(message));
|
|
143
162
|
case 'formatted':
|
|
144
|
-
return beautify(message);
|
|
163
|
+
return limitLogText(beautify(message));
|
|
145
164
|
default:
|
|
146
165
|
return `[${message.type}]`;
|
|
147
166
|
}
|
|
@@ -156,12 +175,13 @@ function createProtoMessageFilter() {
|
|
|
156
175
|
});
|
|
157
176
|
}
|
|
158
177
|
|
|
159
|
-
function createFileTransport(logDirPath: string, logLevel: string) {
|
|
178
|
+
function createFileTransport(logDirPath: string, logLevel: string, maxSize: string, maxFiles: number) {
|
|
160
179
|
return new winston.transports.DailyRotateFile({
|
|
161
180
|
level: logLevel,
|
|
162
181
|
filename: path.join(logDirPath, 'server-%DATE%.log'),
|
|
163
182
|
datePattern: 'YYYY-MM-DD',
|
|
164
|
-
|
|
183
|
+
maxSize,
|
|
184
|
+
maxFiles,
|
|
165
185
|
format: combine(timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), splat(), createFileFormat())
|
|
166
186
|
});
|
|
167
187
|
}
|
|
@@ -174,14 +194,15 @@ function createFileFormat() {
|
|
|
174
194
|
timestamp,
|
|
175
195
|
level,
|
|
176
196
|
event: 'message',
|
|
177
|
-
message
|
|
197
|
+
message: limitLogText(message)
|
|
178
198
|
});
|
|
179
199
|
} else if (isLogObject(logObj.message)) {
|
|
180
200
|
const { timestamp, level, message } = logObj;
|
|
181
201
|
return JSON.stringify({
|
|
182
202
|
timestamp,
|
|
183
203
|
level,
|
|
184
|
-
...message
|
|
204
|
+
...message,
|
|
205
|
+
protoMessage: limitLoggedMessage(message.protoMessage)
|
|
185
206
|
});
|
|
186
207
|
}
|
|
187
208
|
return '';
|
package/src/PlayerConnection.ts
CHANGED
|
@@ -142,12 +142,22 @@ export class PlayerConnection implements IPlayer, LogUtils.IMessageLogger {
|
|
|
142
142
|
} else {
|
|
143
143
|
Logger.warn(`Subscribing to ${streamerId}`);
|
|
144
144
|
this.subscribe(streamerId);
|
|
145
|
+
// subscribe() declines silently, most often because maxSubscribers is reached, and
|
|
146
|
+
// says so only by leaving subscribedStreamer unset. Forwarding anyway dereferences
|
|
147
|
+
// null, which surfaces as an uncaughtException and exits the process.
|
|
148
|
+
if (!this.subscribedStreamer) {
|
|
149
|
+
Logger.error(
|
|
150
|
+
`Player ${this.playerId} could not be subscribed to ${streamerId}. Disconnecting.`
|
|
151
|
+
);
|
|
152
|
+
this.disconnect();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
145
155
|
}
|
|
146
156
|
}
|
|
147
157
|
|
|
148
158
|
message.playerId = this.playerId;
|
|
149
|
-
LogUtils.logForward(this, this.subscribedStreamer
|
|
150
|
-
this.subscribedStreamer
|
|
159
|
+
LogUtils.logForward(this, this.subscribedStreamer, message);
|
|
160
|
+
this.subscribedStreamer.protocol.sendMessage(message);
|
|
151
161
|
}
|
|
152
162
|
|
|
153
163
|
private subscribe(streamerId: string) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
2
|
+
import type WebSocket from 'ws';
|
|
3
|
+
import { Messages } from '@epicgames-ps/lib-pixelstreamingcommon-ue5.8';
|
|
4
|
+
import { SFUConnection } from './SFUConnection';
|
|
5
|
+
import { StreamerConnection } from './StreamerConnection';
|
|
6
|
+
import { StreamerRegistry } from './StreamerRegistry';
|
|
7
|
+
import { PlayerRegistry } from './PlayerRegistry';
|
|
8
|
+
import { SignallingServer } from './SignallingServer';
|
|
9
|
+
|
|
10
|
+
// A streamer the SFU can subscribe to, with no socket behind it.
|
|
11
|
+
function unconnectedStreamer(streamerId: string): StreamerConnection {
|
|
12
|
+
const streamer = new StreamerConnection({} as unknown as SignallingServer, {} as unknown as WebSocket);
|
|
13
|
+
streamer.streamerId = streamerId;
|
|
14
|
+
return streamer;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('SFUConnection subscribe', () => {
|
|
18
|
+
it('leaves the previous streamer when it subscribes to another', () => {
|
|
19
|
+
const a = unconnectedStreamer('A');
|
|
20
|
+
const b = unconnectedStreamer('B');
|
|
21
|
+
const streamerRegistry = new StreamerRegistry();
|
|
22
|
+
streamerRegistry.streamers.push(a, b);
|
|
23
|
+
const server = {
|
|
24
|
+
streamerRegistry,
|
|
25
|
+
playerRegistry: new PlayerRegistry()
|
|
26
|
+
} as unknown as SignallingServer;
|
|
27
|
+
const sfu = new SFUConnection(server, {} as unknown as WebSocket);
|
|
28
|
+
sfu.playerId = 'SFU1';
|
|
29
|
+
|
|
30
|
+
sfu.protocol.emit(Messages.subscribe.typeName, {
|
|
31
|
+
type: Messages.subscribe.typeName,
|
|
32
|
+
streamerId: 'A'
|
|
33
|
+
});
|
|
34
|
+
sfu.protocol.emit(Messages.subscribe.typeName, {
|
|
35
|
+
type: Messages.subscribe.typeName,
|
|
36
|
+
streamerId: 'B'
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(a.subscribers.has('SFU1')).toBe(false);
|
|
40
|
+
expect(b.subscribers.has('SFU1')).toBe(true);
|
|
41
|
+
// A disconnect from the old streamer must not detach the SFU from the one it is on now.
|
|
42
|
+
a.emit('disconnect');
|
|
43
|
+
expect(sfu.subscribedStreamer).toBe(b);
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/SFUConnection.ts
CHANGED
|
@@ -184,6 +184,11 @@ export class SFUConnection extends EventEmitter implements IPlayer, IStreamer, L
|
|
|
184
184
|
return;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// Leave the previous streamer first, as PlayerConnection does, so it stops forwarding to this SFU
|
|
188
|
+
// and a later disconnect of it cannot detach the SFU from the streamer it is now on.
|
|
189
|
+
if (this.subscribedStreamer) {
|
|
190
|
+
this.unsubscribe();
|
|
191
|
+
}
|
|
187
192
|
this.subscribedStreamer = streamer;
|
|
188
193
|
// Register as a subscriber of the upstream streamer so it will forward signalling messages
|
|
189
194
|
// back to this SFU (the streamer's forwardMessage only sends to subscribed players).
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
2
|
+
import * as wslib from 'ws';
|
|
3
|
+
import { SignallingServer, DEFAULT_MAX_PAYLOAD, IServerConfig } from './SignallingServer';
|
|
4
|
+
|
|
5
|
+
// The constructor binds real listening sockets and the class offers no way to close them, so the
|
|
6
|
+
// websocket servers are replaced here and only the options they are created with are checked.
|
|
7
|
+
jest.mock('ws', () => {
|
|
8
|
+
const actual = jest.requireActual<typeof wslib>('ws');
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
WebSocketServer: jest.fn().mockImplementation(() => ({ on: jest.fn() }))
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const STREAMER_PORT = 8888;
|
|
16
|
+
const PLAYER_PORT = 80;
|
|
17
|
+
const SFU_PORT = 8889;
|
|
18
|
+
|
|
19
|
+
function createdOptions(config: Partial<IServerConfig>): Map<number | undefined, wslib.ServerOptions> {
|
|
20
|
+
const constructor = jest.mocked(wslib.WebSocketServer);
|
|
21
|
+
constructor.mockClear();
|
|
22
|
+
new SignallingServer({
|
|
23
|
+
streamerPort: STREAMER_PORT,
|
|
24
|
+
playerPort: PLAYER_PORT,
|
|
25
|
+
sfuPort: SFU_PORT,
|
|
26
|
+
peerOptions: {},
|
|
27
|
+
...config
|
|
28
|
+
});
|
|
29
|
+
const byPort = new Map<number | undefined, wslib.ServerOptions>();
|
|
30
|
+
for (const [options] of constructor.mock.calls) {
|
|
31
|
+
byPort.set(options?.port, options ?? {});
|
|
32
|
+
}
|
|
33
|
+
return byPort;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('SignallingServer websocket message size', () => {
|
|
37
|
+
it('limits every port to DEFAULT_MAX_PAYLOAD', () => {
|
|
38
|
+
const options = createdOptions({});
|
|
39
|
+
|
|
40
|
+
expect(options.size).toBe(3);
|
|
41
|
+
expect(options.get(STREAMER_PORT)?.maxPayload).toBe(DEFAULT_MAX_PAYLOAD);
|
|
42
|
+
expect(options.get(PLAYER_PORT)?.maxPayload).toBe(DEFAULT_MAX_PAYLOAD);
|
|
43
|
+
expect(options.get(SFU_PORT)?.maxPayload).toBe(DEFAULT_MAX_PAYLOAD);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('is 1 MiB', () => {
|
|
47
|
+
expect(DEFAULT_MAX_PAYLOAD).toBe(1024 * 1024);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('lets the websocket options of each port override the limit', () => {
|
|
51
|
+
const options = createdOptions({
|
|
52
|
+
streamerWsOptions: { maxPayload: 4 * 1024 * 1024 },
|
|
53
|
+
playerWsOptions: { maxPayload: 64 * 1024 },
|
|
54
|
+
sfuWsOptions: { maxPayload: 0 }
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(options.get(STREAMER_PORT)?.maxPayload).toBe(4 * 1024 * 1024);
|
|
58
|
+
expect(options.get(PLAYER_PORT)?.maxPayload).toBe(64 * 1024);
|
|
59
|
+
expect(options.get(SFU_PORT)?.maxPayload).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('keeps the other websocket options it is given', () => {
|
|
63
|
+
const verifyClient = jest.fn();
|
|
64
|
+
const options = createdOptions({ playerWsOptions: { verifyClient } });
|
|
65
|
+
|
|
66
|
+
expect(options.get(PLAYER_PORT)?.verifyClient).toBe(verifyClient);
|
|
67
|
+
expect(options.get(PLAYER_PORT)?.maxPayload).toBe(DEFAULT_MAX_PAYLOAD);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
2
|
+
import { SignallingServer, IPeerOptionsRequest, PeerOptionsProvider } from './SignallingServer';
|
|
3
|
+
|
|
4
|
+
const STATIC_PEER_OPTIONS = { iceServers: [{ urls: ['stun:stun.example.com:19302'] }] };
|
|
5
|
+
|
|
6
|
+
const PLAYER_REQUEST: IPeerOptionsRequest = { peerType: 'player', peerId: 'player-1' };
|
|
7
|
+
|
|
8
|
+
// The constructor binds listening sockets and the class offers no way to close them, so a test that
|
|
9
|
+
// built a server properly would leak a listener per case. The peer options path depends on nothing
|
|
10
|
+
// the constructor sets up beyond these two fields, so we supply them directly.
|
|
11
|
+
function serverWith(provider?: PeerOptionsProvider): SignallingServer {
|
|
12
|
+
const server = Object.create(SignallingServer.prototype) as SignallingServer;
|
|
13
|
+
server.config = { streamerPort: 0, peerOptions: STATIC_PEER_OPTIONS, peerOptionsProvider: provider };
|
|
14
|
+
server.protocolConfig = { peerConnectionOptions: STATIC_PEER_OPTIONS };
|
|
15
|
+
return server;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getPeerOptions(server: SignallingServer, request: IPeerOptionsRequest): unknown {
|
|
19
|
+
// Private: called through the type system rather than exported, because the behaviour under test
|
|
20
|
+
// is what a connecting peer is sent, not a public API.
|
|
21
|
+
return (server as unknown as { getPeerOptions(request: IPeerOptionsRequest): unknown }).getPeerOptions(
|
|
22
|
+
request
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('SignallingServer peer options', () => {
|
|
27
|
+
it('sends the configured peer options when no provider is supplied', () => {
|
|
28
|
+
expect(getPeerOptions(serverWith(), PLAYER_REQUEST)).toBe(STATIC_PEER_OPTIONS);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('sends what the provider returns', () => {
|
|
32
|
+
const minted = { iceServers: [{ urls: ['turn:turn.example.com:3478'], username: 'u' }] };
|
|
33
|
+
expect(
|
|
34
|
+
getPeerOptions(
|
|
35
|
+
serverWith(() => minted),
|
|
36
|
+
PLAYER_REQUEST
|
|
37
|
+
)
|
|
38
|
+
).toBe(minted);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('tells the provider which peer is connecting', () => {
|
|
42
|
+
const seen: IPeerOptionsRequest[] = [];
|
|
43
|
+
const server = serverWith((request) => {
|
|
44
|
+
seen.push(request);
|
|
45
|
+
return {};
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
getPeerOptions(server, PLAYER_REQUEST);
|
|
49
|
+
getPeerOptions(server, { peerType: 'streamer', peerId: 'UnknownStreamer' });
|
|
50
|
+
|
|
51
|
+
expect(seen).toEqual([
|
|
52
|
+
{ peerType: 'player', peerId: 'player-1' },
|
|
53
|
+
{ peerType: 'streamer', peerId: 'UnknownStreamer' }
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('falls back to the configured peer options when the provider throws', () => {
|
|
58
|
+
const server = serverWith(() => {
|
|
59
|
+
throw new Error('credential service unreachable');
|
|
60
|
+
});
|
|
61
|
+
expect(getPeerOptions(server, PLAYER_REQUEST)).toBe(STATIC_PEER_OPTIONS);
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/SignallingServer.ts
CHANGED
|
@@ -60,12 +60,54 @@ export interface IServerConfig {
|
|
|
60
60
|
// ships no authentication of its own. See StreamerIdAuthorizer. When omitted, the default
|
|
61
61
|
// behaviour is unchanged (requested id accepted, numeric suffix appended on collision).
|
|
62
62
|
authorizeStreamerId?: StreamerIdAuthorizer;
|
|
63
|
+
|
|
64
|
+
// Optional hook returning the peer configuration to send to a single connecting peer, in place
|
|
65
|
+
// of the static peerOptions. This is the seam for peer configuration that must not be shared
|
|
66
|
+
// between connections - time limited TURN credentials being the motivating case, since a static
|
|
67
|
+
// credential is sent to every peer that ever connects and cannot be changed without a redeploy.
|
|
68
|
+
// See PeerOptionsProvider. When omitted, every peer receives peerOptions unchanged.
|
|
69
|
+
peerOptionsProvider?: PeerOptionsProvider;
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
export type ProtocolConfig = {
|
|
66
73
|
[key: string]: any;
|
|
67
74
|
};
|
|
68
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The kind of peer a set of peer options is being built for. Provided so a consumer can vary what
|
|
78
|
+
* it returns by peer - a streamer connects once and holds its configuration for as long as it runs,
|
|
79
|
+
* where a player receives a fresh one every time it connects.
|
|
80
|
+
*/
|
|
81
|
+
export type PeerType = 'streamer' | 'player' | 'sfu';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Describes the peer that is about to be sent a config message.
|
|
85
|
+
*/
|
|
86
|
+
export interface IPeerOptionsRequest {
|
|
87
|
+
// The kind of peer connecting.
|
|
88
|
+
peerType: PeerType;
|
|
89
|
+
// The id the registry assigned this peer. For a streamer or an SFU this is the placeholder the
|
|
90
|
+
// registry allocates on connect, because a streamer is not named until it sends its endpointId
|
|
91
|
+
// message, which happens after it has been configured. A player id is final.
|
|
92
|
+
peerId: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* An optional consumer-supplied hook that builds the peer configuration for one connecting peer.
|
|
97
|
+
* Return the object to send as peerConnectionOptions in that peer's config message. This is the
|
|
98
|
+
* seam for per-connection credentials without the project committing to a credential scheme of its
|
|
99
|
+
* own. A provider that throws is treated as having no answer, and the static peerOptions are sent.
|
|
100
|
+
*/
|
|
101
|
+
export type PeerOptionsProvider = (request: IPeerOptionsRequest) => unknown;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The largest websocket message, in bytes, accepted on the streamer, player and SFU ports. Signalling
|
|
105
|
+
* messages are a few kilobytes, and ws would otherwise accept up to 100 MiB in one message. A
|
|
106
|
+
* connection that sends more is closed with code 1009. Setting maxPayload in streamerWsOptions,
|
|
107
|
+
* playerWsOptions or sfuWsOptions overrides it for that port.
|
|
108
|
+
*/
|
|
109
|
+
export const DEFAULT_MAX_PAYLOAD = 1024 * 1024;
|
|
110
|
+
|
|
69
111
|
/**
|
|
70
112
|
* The main signalling server object.
|
|
71
113
|
* Contains a streamer and player registry and handles setting up of websockets
|
|
@@ -104,6 +146,7 @@ export class SignallingServer {
|
|
|
104
146
|
const streamerServer = new wslib.WebSocketServer({
|
|
105
147
|
port: config.streamerPort,
|
|
106
148
|
backlog: 1,
|
|
149
|
+
maxPayload: DEFAULT_MAX_PAYLOAD,
|
|
107
150
|
...config.streamerWsOptions
|
|
108
151
|
});
|
|
109
152
|
streamerServer.on('connection', this.onStreamerConnected.bind(this));
|
|
@@ -114,6 +157,7 @@ export class SignallingServer {
|
|
|
114
157
|
const playerServer = new wslib.WebSocketServer({
|
|
115
158
|
server: server,
|
|
116
159
|
port: server ? undefined : config.playerPort,
|
|
160
|
+
maxPayload: DEFAULT_MAX_PAYLOAD,
|
|
117
161
|
...config.playerWsOptions
|
|
118
162
|
});
|
|
119
163
|
playerServer.on('connection', this.onPlayerConnected.bind(this));
|
|
@@ -126,6 +170,7 @@ export class SignallingServer {
|
|
|
126
170
|
const sfuServer = new wslib.WebSocketServer({
|
|
127
171
|
port: config.sfuPort,
|
|
128
172
|
backlog: 1,
|
|
173
|
+
maxPayload: DEFAULT_MAX_PAYLOAD,
|
|
129
174
|
...config.sfuWsOptions
|
|
130
175
|
});
|
|
131
176
|
sfuServer.on('connection', this.onSFUConnected.bind(this));
|
|
@@ -133,15 +178,46 @@ export class SignallingServer {
|
|
|
133
178
|
}
|
|
134
179
|
}
|
|
135
180
|
|
|
136
|
-
private sendConfigMessage(
|
|
181
|
+
private sendConfigMessage(
|
|
182
|
+
connection: { sendMessage(msg: Messages.config): void },
|
|
183
|
+
peerRequest: IPeerOptionsRequest
|
|
184
|
+
): void {
|
|
137
185
|
// peer connection options is a general field with all optional fields;
|
|
138
186
|
// it doesnt play nice with mergePartial so we just add it verbatim
|
|
139
187
|
const message: Messages.config = MessageHelpers.createMessage(Messages.config, this.protocolConfig);
|
|
140
|
-
|
|
141
|
-
message.peerConnectionOptions = this.protocolConfig['peerConnectionOptions'];
|
|
188
|
+
message.peerConnectionOptions = this.getPeerOptions(peerRequest);
|
|
142
189
|
connection.sendMessage(message);
|
|
143
190
|
}
|
|
144
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Resolves the peer options for one connecting peer, deferring to peerOptionsProvider when the
|
|
194
|
+
* consumer supplied one.
|
|
195
|
+
*/
|
|
196
|
+
private getPeerOptions(peerRequest: IPeerOptionsRequest): Messages.config['peerConnectionOptions'] {
|
|
197
|
+
if (!this.config.peerOptionsProvider) {
|
|
198
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
199
|
+
return this.protocolConfig['peerConnectionOptions'];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
// The provider is consumer code, so its return is unknown to us; it travels as an
|
|
204
|
+
// opaque blob in the config message either way.
|
|
205
|
+
return this.config.peerOptionsProvider(peerRequest) as Messages.config['peerConnectionOptions'];
|
|
206
|
+
} catch (error) {
|
|
207
|
+
// A provider is consumer code and may reach outside the process for a credential. If it
|
|
208
|
+
// fails we still send a config message, because a peer that never receives one simply
|
|
209
|
+
// waits forever with nothing in its log to explain why.
|
|
210
|
+
Logger.error(
|
|
211
|
+
'peerOptionsProvider threw for %s peer %s, falling back to the static peer options: %s',
|
|
212
|
+
peerRequest.peerType,
|
|
213
|
+
peerRequest.peerId,
|
|
214
|
+
error instanceof Error ? error.message : stringify(error)
|
|
215
|
+
);
|
|
216
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
217
|
+
return this.protocolConfig['peerConnectionOptions'];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
145
221
|
private onStreamerConnected(ws: wslib.WebSocket, request: http.IncomingMessage) {
|
|
146
222
|
Logger.info(`New streamer connection: %s`, request.socket.remoteAddress);
|
|
147
223
|
|
|
@@ -159,7 +235,7 @@ export class SignallingServer {
|
|
|
159
235
|
);
|
|
160
236
|
});
|
|
161
237
|
|
|
162
|
-
this.sendConfigMessage(newStreamer);
|
|
238
|
+
this.sendConfigMessage(newStreamer, { peerType: 'streamer', peerId: newStreamer.streamerId });
|
|
163
239
|
}
|
|
164
240
|
|
|
165
241
|
private onPlayerConnected(ws: wslib.WebSocket, request: http.IncomingMessage) {
|
|
@@ -193,7 +269,7 @@ export class SignallingServer {
|
|
|
193
269
|
};
|
|
194
270
|
}
|
|
195
271
|
|
|
196
|
-
this.sendConfigMessage(newPlayer);
|
|
272
|
+
this.sendConfigMessage(newPlayer, { peerType: 'player', peerId: newPlayer.playerId });
|
|
197
273
|
}
|
|
198
274
|
|
|
199
275
|
private onSFUConnected(ws: wslib.WebSocket, request: http.IncomingMessage) {
|
|
@@ -209,6 +285,6 @@ export class SignallingServer {
|
|
|
209
285
|
Logger.info(`SFU %s (%s) disconnected.`, newSFU.streamerId, request.socket.remoteAddress);
|
|
210
286
|
});
|
|
211
287
|
|
|
212
|
-
this.sendConfigMessage(newSFU);
|
|
288
|
+
this.sendConfigMessage(newSFU, { peerType: 'sfu', peerId: newSFU.streamerId });
|
|
213
289
|
}
|
|
214
290
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
2
|
+
import type WebSocket from 'ws';
|
|
3
|
+
import { StreamerConnection } from './StreamerConnection';
|
|
4
|
+
import { SignallingServer } from './SignallingServer';
|
|
5
|
+
|
|
6
|
+
describe('StreamerConnection disconnectPlayer reason', () => {
|
|
7
|
+
// A streamer with one subscribed player whose protocol records the close it is asked for.
|
|
8
|
+
function streamerWithPlayer() {
|
|
9
|
+
const disconnect = jest.fn();
|
|
10
|
+
const player = { protocol: { disconnect } };
|
|
11
|
+
const server = { playerRegistry: { get: () => player } } as unknown as SignallingServer;
|
|
12
|
+
const streamer = new StreamerConnection(server, {} as unknown as WebSocket);
|
|
13
|
+
streamer.subscribers = new Set(['player-1']);
|
|
14
|
+
return { streamer, disconnect };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function disconnectPlayer(streamer: StreamerConnection, reason: unknown): void {
|
|
18
|
+
streamer.protocol.emit('disconnectPlayer', {
|
|
19
|
+
type: 'disconnectPlayer',
|
|
20
|
+
playerId: 'player-1',
|
|
21
|
+
reason
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
it('passes a short string reason through', () => {
|
|
26
|
+
const { streamer, disconnect } = streamerWithPlayer();
|
|
27
|
+
disconnectPlayer(streamer, 'bye');
|
|
28
|
+
expect(disconnect).toHaveBeenCalledWith(1011, 'bye');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('closes without a reason when the reason is over 123 bytes', () => {
|
|
32
|
+
const { streamer, disconnect } = streamerWithPlayer();
|
|
33
|
+
disconnectPlayer(streamer, 'x'.repeat(124));
|
|
34
|
+
expect(disconnect).toHaveBeenCalledWith(1011, undefined);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('counts the reason in bytes, not characters', () => {
|
|
38
|
+
const { streamer, disconnect } = streamerWithPlayer();
|
|
39
|
+
disconnectPlayer(streamer, String.fromCharCode(0xe9).repeat(62)); // 124 bytes in UTF-8
|
|
40
|
+
expect(disconnect).toHaveBeenCalledWith(1011, undefined);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('closes without a reason when the reason is not a string', () => {
|
|
44
|
+
const { streamer, disconnect } = streamerWithPlayer();
|
|
45
|
+
disconnectPlayer(streamer, { length: 1 });
|
|
46
|
+
expect(disconnect).toHaveBeenCalledWith(1011, undefined);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -179,7 +179,13 @@ export class StreamerConnection extends EventEmitter implements IStreamer, LogUt
|
|
|
179
179
|
}
|
|
180
180
|
const player = this.server.playerRegistry.get(message.playerId);
|
|
181
181
|
if (player) {
|
|
182
|
-
|
|
182
|
+
// ws throws on a reason over 123 bytes or one that is not a string, which would crash the
|
|
183
|
+
// server. Such a reason is dropped; the player is still closed.
|
|
184
|
+
const reason =
|
|
185
|
+
typeof message.reason === 'string' && Buffer.byteLength(message.reason) <= 123
|
|
186
|
+
? message.reason
|
|
187
|
+
: undefined;
|
|
188
|
+
player.protocol.disconnect(1011, reason);
|
|
183
189
|
}
|
|
184
190
|
}
|
|
185
191
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
2
|
+
import type WebSocket from 'ws';
|
|
3
|
+
import { Messages } from '@epicgames-ps/lib-pixelstreamingcommon-ue5.8';
|
|
4
|
+
import { StreamerConnection } from './StreamerConnection';
|
|
5
|
+
import { StreamerRegistry } from './StreamerRegistry';
|
|
6
|
+
import { SignallingServer } from './SignallingServer';
|
|
7
|
+
|
|
8
|
+
// A streamer the registry can add and talk to, with no socket behind it.
|
|
9
|
+
function unconnectedStreamer(): StreamerConnection {
|
|
10
|
+
return new StreamerConnection({} as unknown as SignallingServer, {} as unknown as WebSocket);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('StreamerRegistry endpointId handling', () => {
|
|
14
|
+
it('treats a non-string id as no id, so a later registration still works', () => {
|
|
15
|
+
const registry = new StreamerRegistry();
|
|
16
|
+
const first = unconnectedStreamer();
|
|
17
|
+
registry.add(first);
|
|
18
|
+
|
|
19
|
+
// The id arrives as any JSON value; a number here used to be stored as the streamer id.
|
|
20
|
+
first.protocol.emit(Messages.endpointId.typeName, { type: Messages.endpointId.typeName, id: 5 });
|
|
21
|
+
expect(typeof first.streamerId).toBe('string');
|
|
22
|
+
|
|
23
|
+
const second = unconnectedStreamer();
|
|
24
|
+
expect(() => registry.add(second)).not.toThrow();
|
|
25
|
+
expect(second.streamerId).not.toBe(first.streamerId);
|
|
26
|
+
});
|
|
27
|
+
});
|