@3dsource/angular-unreal-module 0.0.123-dev.0 → 0.0.124
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { InjectionToken, inject, ChangeDetectionStrategy, Component, Injectable, DestroyRef, effect, untracked, computed, signal, Pipe, input, ElementRef, viewChild, afterNextRender, HostListener, output, makeEnvironmentProviders, provideEnvironmentInitializer } from '@angular/core';
|
|
3
3
|
import { SourceButtonComponent, SourceLoadingComponent } from '@3dsource/source-ui-native';
|
|
4
|
-
import { merge, map, scan, timestamp, startWith, pairwise, concatMap, of, animationFrameScheduler, interval, Observable, skip, switchMap, concat, delay, debounceTime, timer, take, timeout, mergeMap, EMPTY, Subject, share, takeUntil as takeUntil$1, shareReplay, from, fromEvent, throwError, defer, retry, tap as tap$1, throttleTime, NEVER, asyncScheduler, filter as filter$1, withLatestFrom as withLatestFrom$1, Subscription, bufferTime, combineLatestWith, distinctUntilChanged as distinctUntilChanged$1 } from 'rxjs';
|
|
4
|
+
import { merge, map, scan, timestamp, startWith, pairwise, concatMap, of, animationFrameScheduler, interval, Observable, skip, switchMap, concat, delay, debounceTime, timer, take, timeout, mergeMap, EMPTY, Subject, share, takeUntil as takeUntil$1, shareReplay, from, fromEvent, throwError, defer, retry, repeat, tap as tap$1, throttleTime, NEVER, asyncScheduler, filter as filter$1, withLatestFrom as withLatestFrom$1, Subscription, bufferTime, combineLatestWith, distinctUntilChanged as distinctUntilChanged$1 } from 'rxjs';
|
|
5
5
|
import { createAction, props, createReducer, on, createFeature, createSelector, Store, provideState } from '@ngrx/store';
|
|
6
6
|
import { concatLatestFrom } from '@ngrx/operators';
|
|
7
7
|
import { Actions, createEffect, ofType, provideEffects } from '@ngrx/effects';
|
|
8
8
|
import { whereNot, Logger, calculateMedian, clampf, lerp, getCanvasCached, getSnapshot, fitIntoRectangle, Truthy, tapLog, Falsy, generateUuid, COLOR_CODES, where, KeyboardNumericCode, InvertedKeyMap, Semaphore, HEXtoRGB, RGBtoHSV, inverseLerp, HSVtoRGB, RGBtoHEX, fpIsASameAsB } from '@3dsource/utils';
|
|
9
|
-
import { map as map$1, distinctUntilChanged, takeWhile, filter, tap, takeUntil, catchError, withLatestFrom, exhaustMap, switchMap as switchMap$1, timeout as timeout$1,
|
|
9
|
+
import { map as map$1, distinctUntilChanged, takeWhile, filter, tap, takeUntil, catchError, withLatestFrom, exhaustMap, switchMap as switchMap$1, timeout as timeout$1, finalize, first, debounceTime as debounceTime$1, take as take$1, startWith as startWith$1 } from 'rxjs/operators';
|
|
10
10
|
import { DialogRef, DIALOG_DATA, Dialog } from '@angular/cdk/dialog';
|
|
11
11
|
import { HttpClient } from '@angular/common/http';
|
|
12
12
|
import { ScrollStrategyOptions } from '@angular/cdk/overlay';
|
|
@@ -207,8 +207,31 @@ const CONSOLE_COMMAND_DISABLE_MESSAGES = 'DisableAllScreenMessages';
|
|
|
207
207
|
const CONSOLE_COMMAND_PIXEL_QUALITY = 'PixelStreaming.FreezeFrameQuality 95';
|
|
208
208
|
const FULL_HD_WIDTH = 1920;
|
|
209
209
|
const FULL_HD_HEIGHT = 1080;
|
|
210
|
-
|
|
210
|
+
// Bound on a single WS upgrade attempt (`createWebSocket`). On cold
|
|
211
|
+
// connections the TCP+TLS+WS handshake can legitimately take 3-5 s
|
|
212
|
+
// (cold DNS, TLS session miss). 2 s was too aggressive — first
|
|
213
|
+
// attempts kept timing out and the second attempt (with warm caches)
|
|
214
|
+
// would connect in <500 ms. 10 s gives slow networks room without
|
|
215
|
+
// letting a truly hung handshake hold up `connectToCirrus` forever.
|
|
216
|
+
const WS_TIMEOUT = 6000;
|
|
211
217
|
const POLLING_TIME = 4000;
|
|
218
|
+
// Staged retry policy for `SignallingService.connectToSignaling`:
|
|
219
|
+
// INNER_RETRY_LIMIT = max number of WS+orchestration attempts before
|
|
220
|
+
// the pipeline gives up on the current region and bubbles the error
|
|
221
|
+
// to the outer retry. URL rotation (`urlGen`) advances across these.
|
|
222
|
+
// OUTER_RETRY_DELAY = backoff between region re-picks. Intentionally
|
|
223
|
+
// longer than POLLING_TIME so region discovery is rare relative to
|
|
224
|
+
// WS retries.
|
|
225
|
+
const INNER_RETRY_LIMIT = 3;
|
|
226
|
+
const OUTER_RETRY_DELAY = POLLING_TIME * 2;
|
|
227
|
+
// Extra WS-only retries on the SAME wsUrl (cached AwsInstance) BEFORE
|
|
228
|
+
// falling through to a URL-rotating retry that re-polls orchestration.
|
|
229
|
+
// Why: a fresh `default` poll asks orchestrator to RESERVE a new
|
|
230
|
+
// instance — the previous reservation is orphaned (server-side leak).
|
|
231
|
+
// One quick same-wsUrl retry catches transient WS failures (network
|
|
232
|
+
// blip, brief unavailability) without burning a second reservation.
|
|
233
|
+
// Total WS attempts before URL rotation = 1 + WS_RETRY_PER_INSTANCE.
|
|
234
|
+
const WS_RETRY_PER_INSTANCE = 1;
|
|
212
235
|
const WS_OPEN_STATE = 1;
|
|
213
236
|
const DEFAULT_AFK_TIMEOUT_PERIOD = 15;
|
|
214
237
|
const DEFAULT_AFK_TIMEOUT = 600; // 10 minutes
|
|
@@ -1672,9 +1695,9 @@ class AFKService extends SubService {
|
|
|
1672
1695
|
resetAfkWarningTimer() {
|
|
1673
1696
|
if (this.active) {
|
|
1674
1697
|
this.clearTimers();
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1698
|
+
// `selectWarnTimeout` already falls back to DEFAULT_AFK_TIMEOUT when
|
|
1699
|
+
// config is missing, so warnTimeout() always returns a positive number.
|
|
1700
|
+
this.warnTimer = setTimeout(() => this.showAfkOverlay(), this.warnTimeout() * 1000);
|
|
1678
1701
|
}
|
|
1679
1702
|
}
|
|
1680
1703
|
/**
|
|
@@ -1694,7 +1717,8 @@ class AFKService extends SubService {
|
|
|
1694
1717
|
}
|
|
1695
1718
|
reset() {
|
|
1696
1719
|
this.hideOverlay();
|
|
1697
|
-
|
|
1720
|
+
// startAfkWarningTimer → resetAfkWarningTimer → clearTimers() already
|
|
1721
|
+
// clears the countdown interval, no separate clearInterval needed.
|
|
1698
1722
|
this.startAfkWarningTimer();
|
|
1699
1723
|
}
|
|
1700
1724
|
showAfkOverlay() {
|
|
@@ -1917,10 +1941,7 @@ class VideoService extends SubService {
|
|
|
1917
1941
|
interval(250).pipe(map(() => track.pcClient), switchMap$1((pcClient) => from(this.getStats(pcClient))), filter(Truthy))
|
|
1918
1942
|
: of(null)), filter(Truthy), share());
|
|
1919
1943
|
this.destroyAbort = new AbortController();
|
|
1920
|
-
this.destroyRef.onDestroy(() =>
|
|
1921
|
-
this.destroyAbort.abort();
|
|
1922
|
-
this.destroy();
|
|
1923
|
-
});
|
|
1944
|
+
this.destroyRef.onDestroy(() => this.destroy());
|
|
1924
1945
|
}
|
|
1925
1946
|
setContainer(container = null) {
|
|
1926
1947
|
this.container = container;
|
|
@@ -1932,6 +1953,9 @@ class VideoService extends SubService {
|
|
|
1932
1953
|
this.VideoEncoderQP = Number(new TextDecoder('utf-16').decode(data));
|
|
1933
1954
|
}
|
|
1934
1955
|
create() {
|
|
1956
|
+
if (!this.container) {
|
|
1957
|
+
Logger.warn('VideoService.create(): no container set; <video> will be built but never attached to the DOM, which suppresses autoplay.');
|
|
1958
|
+
}
|
|
1935
1959
|
this.disconnect$
|
|
1936
1960
|
.pipe(take(1), takeUntil$1(this.destroy$))
|
|
1937
1961
|
.subscribe(() => this.destroy());
|
|
@@ -1940,26 +1964,35 @@ class VideoService extends SubService {
|
|
|
1940
1964
|
this.createWebRtcAudio();
|
|
1941
1965
|
}
|
|
1942
1966
|
attachVideoStream(stream, pcClient) {
|
|
1943
|
-
if (this.video) {
|
|
1944
|
-
this.video.srcObject = stream;
|
|
1945
|
-
this.videoTrack$.next({ stream, pcClient });
|
|
1946
|
-
}
|
|
1947
|
-
else {
|
|
1967
|
+
if (!this.video) {
|
|
1948
1968
|
Logger.error('Video element is not defined.');
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
// Safari refuses muted-autoplay for MediaStreams that carry unmuted
|
|
1972
|
+
// audio tracks, even when the <video> is muted. Route video tracks to
|
|
1973
|
+
// the video element and audio tracks (if any) to the audio element.
|
|
1974
|
+
this.video.srcObject = new MediaStream(stream.getVideoTracks());
|
|
1975
|
+
const audioTracks = stream.getAudioTracks();
|
|
1976
|
+
if (audioTracks.length && this.audio) {
|
|
1977
|
+
this.audio.srcObject = new MediaStream(audioTracks);
|
|
1978
|
+
this.playAudio(this.audio);
|
|
1949
1979
|
}
|
|
1980
|
+
this.videoTrack$.next({ stream, pcClient });
|
|
1981
|
+
// Safari does not reliably honor the autoplay attribute when srcObject
|
|
1982
|
+
// is bound post-creation for a WebRTC MediaStream; trigger play()
|
|
1983
|
+
// explicitly and let safePlay() fall back if Safari refuses.
|
|
1984
|
+
void this.safePlay(this.video);
|
|
1950
1985
|
}
|
|
1951
1986
|
attachAudioStream(stream) {
|
|
1952
|
-
|
|
1953
|
-
if (this.video?.srcObject === stream) {
|
|
1987
|
+
if (!this.audio) {
|
|
1954
1988
|
return;
|
|
1955
1989
|
}
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
this.video?.srcObject !== stream) {
|
|
1960
|
-
this.audio.srcObject = stream;
|
|
1961
|
-
this.playAudio(this.audio);
|
|
1990
|
+
const audioTracks = stream.getAudioTracks();
|
|
1991
|
+
if (!audioTracks.length) {
|
|
1992
|
+
return;
|
|
1962
1993
|
}
|
|
1994
|
+
this.audio.srcObject = new MediaStream(audioTracks);
|
|
1995
|
+
this.playAudio(this.audio);
|
|
1963
1996
|
}
|
|
1964
1997
|
play() {
|
|
1965
1998
|
void this.safePlay(this.video);
|
|
@@ -1977,7 +2010,8 @@ class VideoService extends SubService {
|
|
|
1977
2010
|
typeof error === 'object' &&
|
|
1978
2011
|
'name' in error &&
|
|
1979
2012
|
error?.name !== 'NotAllowedError') {
|
|
1980
|
-
|
|
2013
|
+
Logger.warn('safePlay() rejected with non-autoplay error:', error);
|
|
2014
|
+
return;
|
|
1981
2015
|
}
|
|
1982
2016
|
// Try silent inline autoplay
|
|
1983
2017
|
video.muted = true;
|
|
@@ -2022,6 +2056,11 @@ class VideoService extends SubService {
|
|
|
2022
2056
|
}
|
|
2023
2057
|
try {
|
|
2024
2058
|
const stats = await pcClient.getStats(null);
|
|
2059
|
+
// The service may have been destroyed while pcClient.getStats() was
|
|
2060
|
+
// pending; bail before writing back to this.aggregatedStats.
|
|
2061
|
+
if (!this.video) {
|
|
2062
|
+
return null;
|
|
2063
|
+
}
|
|
2025
2064
|
return this.generateAggregatedStatsFunction(stats);
|
|
2026
2065
|
}
|
|
2027
2066
|
catch (err) {
|
|
@@ -2101,12 +2140,14 @@ class VideoService extends SubService {
|
|
|
2101
2140
|
if (report.type === 'transport' &&
|
|
2102
2141
|
Object.hasOwn(report, 'selectedCandidatePairId')) {
|
|
2103
2142
|
const selectedPair = stats.get(report.selectedCandidatePairId);
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2143
|
+
if (selectedPair) {
|
|
2144
|
+
const local = stats.get(selectedPair.localCandidateId);
|
|
2145
|
+
const remote = stats.get(selectedPair.remoteCandidateId);
|
|
2146
|
+
newStat.selectedPair = {
|
|
2147
|
+
local: { type: local?.candidateType, url: local?.url },
|
|
2148
|
+
remote: { type: remote?.candidateType, url: remote?.url },
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2110
2151
|
}
|
|
2111
2152
|
// Store mimetype of each codec
|
|
2112
2153
|
if (Object.hasOwn(newStat, 'codecs') &&
|
|
@@ -2146,6 +2187,19 @@ class VideoService extends SubService {
|
|
|
2146
2187
|
return;
|
|
2147
2188
|
}
|
|
2148
2189
|
this.changeStatusMainVideoOnScene(false);
|
|
2190
|
+
// Cancel any gesture listeners armed by safePlay/playAudio in this
|
|
2191
|
+
// session, then arm a fresh controller for the next one. Without this,
|
|
2192
|
+
// listeners survive mid-session disconnect$ → destroy() cycles.
|
|
2193
|
+
this.destroyAbort.abort();
|
|
2194
|
+
this.destroyAbort = new AbortController();
|
|
2195
|
+
// Pause and detach streams before nulling — Safari can otherwise hold
|
|
2196
|
+
// on to the RTCRtpReceiver until GC eventually clears srcObject.
|
|
2197
|
+
this.video.pause();
|
|
2198
|
+
this.video.srcObject = null;
|
|
2199
|
+
if (this.audio) {
|
|
2200
|
+
this.audio.pause();
|
|
2201
|
+
this.audio.srcObject = null;
|
|
2202
|
+
}
|
|
2149
2203
|
this.video = null;
|
|
2150
2204
|
this.audio = null;
|
|
2151
2205
|
this.videoTrack$.next(null);
|
|
@@ -2170,7 +2224,14 @@ class VideoService extends SubService {
|
|
|
2170
2224
|
video.id = STREAMING_VIDEO_ID;
|
|
2171
2225
|
video.playsInline = true;
|
|
2172
2226
|
video.muted = true;
|
|
2173
|
-
|
|
2227
|
+
// Safari occasionally resets `muted` when srcObject is rebound; the
|
|
2228
|
+
// `defaultMuted` reflection makes the muted state sticky and keeps the
|
|
2229
|
+
// muted-autoplay policy intact.
|
|
2230
|
+
video.defaultMuted = true;
|
|
2231
|
+
// No `autoplay` attribute on purpose: attachVideoStream() always calls
|
|
2232
|
+
// safePlay() explicitly, and the attribute would create a second
|
|
2233
|
+
// browser-internal play() Promise that we can't catch — which surfaces
|
|
2234
|
+
// as an unhandled rejection when destroy() pauses the element.
|
|
2174
2235
|
video.preload = 'auto';
|
|
2175
2236
|
video.disablePictureInPicture = true;
|
|
2176
2237
|
this.video = video;
|
|
@@ -2187,13 +2248,36 @@ class VideoService extends SubService {
|
|
|
2187
2248
|
audio.id = STREAMING_AUDIO_ID;
|
|
2188
2249
|
this.audio = audio;
|
|
2189
2250
|
}
|
|
2190
|
-
playAudio(audio) {
|
|
2191
|
-
|
|
2192
|
-
.
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2251
|
+
async playAudio(audio) {
|
|
2252
|
+
try {
|
|
2253
|
+
await audio.play();
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
catch {
|
|
2257
|
+
// Wait for the next real user gesture and retry.
|
|
2258
|
+
const resume = async () => {
|
|
2259
|
+
try {
|
|
2260
|
+
await audio.play();
|
|
2261
|
+
}
|
|
2262
|
+
catch {
|
|
2263
|
+
/* ignore — caller may need another gesture */
|
|
2264
|
+
}
|
|
2265
|
+
finally {
|
|
2266
|
+
window.removeEventListener('pointerdown', resume, true);
|
|
2267
|
+
window.removeEventListener('keydown', resume, true);
|
|
2268
|
+
}
|
|
2269
|
+
};
|
|
2270
|
+
window.addEventListener('pointerdown', resume, {
|
|
2271
|
+
once: true,
|
|
2272
|
+
capture: true,
|
|
2273
|
+
signal: this.destroyAbort.signal,
|
|
2274
|
+
});
|
|
2275
|
+
window.addEventListener('keydown', resume, {
|
|
2276
|
+
once: true,
|
|
2277
|
+
capture: true,
|
|
2278
|
+
signal: this.destroyAbort.signal,
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2197
2281
|
}
|
|
2198
2282
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: VideoService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
2199
2283
|
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: VideoService }); }
|
|
@@ -2202,11 +2286,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2202
2286
|
type: Injectable
|
|
2203
2287
|
}], ctorParameters: () => [] });
|
|
2204
2288
|
|
|
2205
|
-
class CommandTelemetryService {
|
|
2289
|
+
class CommandTelemetryService extends SubService {
|
|
2206
2290
|
constructor() {
|
|
2207
|
-
|
|
2208
|
-
this.destroyRef = inject(DestroyRef);
|
|
2209
|
-
this.destroy$ = new Subject();
|
|
2291
|
+
super();
|
|
2210
2292
|
this.appId = 'metabox-telemetry';
|
|
2211
2293
|
this.commandsSent = {};
|
|
2212
2294
|
this.exileTimeout = 60000;
|
|
@@ -2214,13 +2296,7 @@ class CommandTelemetryService {
|
|
|
2214
2296
|
this.commandSequenceNumber = 0;
|
|
2215
2297
|
this.payloads = [];
|
|
2216
2298
|
this.httpClient = inject(HttpClient);
|
|
2217
|
-
this.unrealConfig = inject(UNREAL_CONFIG, {
|
|
2218
|
-
optional: true,
|
|
2219
|
-
});
|
|
2220
|
-
this.destroyRef.onDestroy(() => {
|
|
2221
|
-
this.destroy$.next();
|
|
2222
|
-
this.destroy$.complete();
|
|
2223
|
-
});
|
|
2299
|
+
this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
|
|
2224
2300
|
this.init();
|
|
2225
2301
|
}
|
|
2226
2302
|
init() {
|
|
@@ -2654,32 +2730,35 @@ class SignallingService extends SubService {
|
|
|
2654
2730
|
this.onConfig$ = new Subject();
|
|
2655
2731
|
this.onWebRtcIce$ = new Subject();
|
|
2656
2732
|
this.onWebRtcAnswer$ = new Subject();
|
|
2657
|
-
//
|
|
2658
|
-
//
|
|
2659
|
-
//
|
|
2660
|
-
|
|
2661
|
-
this.abort$ = this.actions$.pipe(ofType(abortEstablishingConnection), tap(() => this.close({
|
|
2662
|
-
code: 1000,
|
|
2663
|
-
reason: DisconnectReason.AbortEstablishingConnection,
|
|
2664
|
-
})), share$1());
|
|
2733
|
+
// Pure signal — no side-effects. The `close()` is wired ONCE in init()
|
|
2734
|
+
// below so a single abort event triggers exactly one close, regardless
|
|
2735
|
+
// of how many pipelines have `takeUntil(this.abort$)` attached.
|
|
2736
|
+
this.abort$ = this.actions$.pipe(ofType(abortEstablishingConnection));
|
|
2665
2737
|
this.ws = null;
|
|
2666
2738
|
this.wsMsgHandlers = {};
|
|
2667
2739
|
this.init();
|
|
2668
|
-
this.destroyRef.onDestroy(() => this.close({
|
|
2669
|
-
code: 1000,
|
|
2670
|
-
reason: DisconnectReason.AbortEstablishingConnection,
|
|
2671
|
-
}));
|
|
2672
2740
|
}
|
|
2673
2741
|
init() {
|
|
2674
2742
|
if (this.unrealConfig?.playwright) {
|
|
2675
2743
|
return;
|
|
2676
2744
|
}
|
|
2677
2745
|
this.setHandlersFromStream();
|
|
2678
|
-
//
|
|
2679
|
-
//
|
|
2680
|
-
//
|
|
2681
|
-
|
|
2682
|
-
|
|
2746
|
+
// Single owner of the "close on abort" side-effect. Previously this
|
|
2747
|
+
// lived as a `tap` inside `abort$` itself, which made every
|
|
2748
|
+
// `takeUntil(this.abort$)` subscriber fire its own close().
|
|
2749
|
+
this.abort$.pipe(takeUntil(this.destroy$)).subscribe(() => {
|
|
2750
|
+
this.close({
|
|
2751
|
+
code: 1000,
|
|
2752
|
+
reason: DisconnectReason.AbortEstablishingConnection,
|
|
2753
|
+
});
|
|
2754
|
+
});
|
|
2755
|
+
// `dataChannelReady` is a per-session event. Bind ONLY to destroy$, not
|
|
2756
|
+
// to abort$ — `abort$` fires on every destroyUnrealScene (route swap),
|
|
2757
|
+
// and `init()` runs once in the constructor. If these subs were torn
|
|
2758
|
+
// down by abort$, a second in-injector session (e.g. navigating between
|
|
2759
|
+
// two routes that both host <app-unreal-scene>) would never send
|
|
2760
|
+
// `p2pEstablished` / `streamPreparedForUserInteraction`. `send()` is
|
|
2761
|
+
// already a no-op on a closed WS, so there's no risk of stray messages.
|
|
2683
2762
|
this.actions$
|
|
2684
2763
|
.pipe(ofType(dataChannelReady), takeUntil(this.destroy$))
|
|
2685
2764
|
.subscribe(() => {
|
|
@@ -2709,36 +2788,93 @@ class SignallingService extends SubService {
|
|
|
2709
2788
|
return EMPTY;
|
|
2710
2789
|
}
|
|
2711
2790
|
Logger.log('CONNECT TO SIGNALLING');
|
|
2791
|
+
// Side-effects that belong to one logical `connectToSignaling`
|
|
2792
|
+
// invocation (NOT one retry attempt). Wrapping them in a `defer`
|
|
2793
|
+
// makes them fire on subscribe (lazy), but only ONCE — the retry
|
|
2794
|
+
// below sits INSIDE this defer's projection so re-subscribing the
|
|
2795
|
+
// inner pipeline does not re-trigger these.
|
|
2712
2796
|
return defer(() => {
|
|
2713
|
-
// Preparation for WebSocket Orchestration. Inside defer so these
|
|
2714
|
-
// side effects only fire once the caller actually subscribes.
|
|
2715
2797
|
this.correlationId = generateUuid();
|
|
2716
2798
|
this.commandTelemetryService.reset();
|
|
2717
2799
|
this.startEstablishingConnection();
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2800
|
+
// Staged retry:
|
|
2801
|
+
// * `getRegion()` is pulled in front of the retry — region
|
|
2802
|
+
// discovery happens once per outer cycle.
|
|
2803
|
+
// * `connectAttempt(urls)` owns inner retry + URL rotation +
|
|
2804
|
+
// WS connect. WS/orch failures stay inside it (capped at
|
|
2805
|
+
// `INNER_RETRY_LIMIT`).
|
|
2806
|
+
// * If the inner retry exhausts, the error propagates to the
|
|
2807
|
+
// OUTER retry below which re-runs `getRegion()` (rare, real
|
|
2808
|
+
// "region down" case). Outer delay > inner so escalation is
|
|
2809
|
+
// bounded.
|
|
2810
|
+
const connectCycle$ = this.getRegion().pipe(tap((region) => this.region.set(region ?? '')), map$1((region) => this.adaptUrlsToRegion(urlList, region)), switchMap((urls) => this.connectAttempt(urls)), takeUntil(this.abort$));
|
|
2811
|
+
return connectCycle$.pipe(retry({
|
|
2812
|
+
delay: (err, attempt) => {
|
|
2813
|
+
this.store.dispatch(signalingRetryAttempted({
|
|
2814
|
+
attempt,
|
|
2815
|
+
reason: err?.message ?? 'unknown',
|
|
2816
|
+
kind: 'region-cycle',
|
|
2817
|
+
}));
|
|
2818
|
+
Logger.info(`Outer retry (region re-pick) in ${OUTER_RETRY_DELAY}ms. Attempt# ${attempt}.`, err?.message);
|
|
2819
|
+
return timer(OUTER_RETRY_DELAY);
|
|
2820
|
+
},
|
|
2821
|
+
}));
|
|
2822
|
+
}).pipe(takeUntil(this.abort$));
|
|
2823
|
+
}
|
|
2824
|
+
/**
|
|
2825
|
+
* One inner cycle: rotate `urlGen` over the pool, resolve via
|
|
2826
|
+
* {@link getAwsInstance}, open WS via {@link connectToCirrus},
|
|
2827
|
+
* retry up to {@link INNER_RETRY_LIMIT} on any hard failure. The
|
|
2828
|
+
* urlGen lives in this closure so retries advance through the
|
|
2829
|
+
* pool. Exhaustion bubbles to {@link connectToSignaling}'s outer
|
|
2830
|
+
* retry which re-pings the region.
|
|
2831
|
+
*/
|
|
2832
|
+
connectAttempt(urls) {
|
|
2833
|
+
const urlGen = getActiveUrl(urls);
|
|
2834
|
+
return defer(() => this.getAwsInstance(`${urlGen.next().value}`)).pipe(tap((awsInstance) => this.store.dispatch(setAwsInstance(awsInstance))), switchMap(({ wsUrl }) =>
|
|
2835
|
+
// ── WS-only retry tier ─────────────────────────────────────────────
|
|
2836
|
+
// Tries the SAME `wsUrl` up to `WS_RETRY_PER_INSTANCE` extra times
|
|
2837
|
+
// before letting the error bubble up to the outer (URL-rotating)
|
|
2838
|
+
// retry. This is important because the outer retry calls
|
|
2839
|
+
// `getAwsInstance(url)` again, which polls orchestration again,
|
|
2840
|
+
// which causes orchestrator to RESERVE A NEW instance — the
|
|
2841
|
+
// previous reservation is orphaned (server-side leak).
|
|
2842
|
+
//
|
|
2843
|
+
// The same-wsUrl retry catches transient WS failures (network
|
|
2844
|
+
// blip, brief region unavailability, slow cold handshake) without
|
|
2845
|
+
// burning a second reservation. If WS still fails after these
|
|
2846
|
+
// extra attempts, the instance is genuinely unusable and we
|
|
2847
|
+
// accept the cost of rotating.
|
|
2848
|
+
this.connectToCirrus(wsUrl ?? '').pipe(filter(Truthy), take(1), retry({
|
|
2849
|
+
count: WS_RETRY_PER_INSTANCE,
|
|
2850
|
+
delay: (err, attempt) => {
|
|
2851
|
+
this.store.dispatch(signalingRetryAttempted({
|
|
2852
|
+
attempt,
|
|
2853
|
+
reason: err?.message ?? 'unknown',
|
|
2854
|
+
kind: 'ws-retry',
|
|
2855
|
+
}));
|
|
2856
|
+
this.showStatusMessage(UnrealStatusMessage.CONNECTING_TO_SESSION);
|
|
2857
|
+
Logger.info(`WS-only retry on SAME url in ${POLLING_TIME}ms. Attempt# ${attempt}.`, err?.message);
|
|
2858
|
+
return timer(POLLING_TIME);
|
|
2859
|
+
},
|
|
2860
|
+
}))),
|
|
2861
|
+
// ── URL-rotating retry tier ─────────────────────────────────────────
|
|
2862
|
+
// Triggers only when the WS-only retries above are exhausted. Each
|
|
2863
|
+
// attempt advances `urlGen` → new `getAwsInstance(url)` → new
|
|
2864
|
+
// orchestration poll → new AwsInstance reservation.
|
|
2725
2865
|
retry({
|
|
2866
|
+
count: INNER_RETRY_LIMIT,
|
|
2726
2867
|
delay: (err, attempt) => {
|
|
2727
2868
|
this.store.dispatch(signalingRetryAttempted({
|
|
2728
2869
|
attempt,
|
|
2729
2870
|
reason: err?.message ?? 'unknown',
|
|
2730
2871
|
kind: 'connect',
|
|
2731
2872
|
}));
|
|
2732
|
-
|
|
2873
|
+
this.showStatusMessage(UnrealStatusMessage.CONNECTING_TO_SESSION);
|
|
2874
|
+
Logger.info(`Inner retry (WS+orch) in ${POLLING_TIME}ms. Attempt# ${attempt}.`, err?.message);
|
|
2733
2875
|
return timer(POLLING_TIME);
|
|
2734
2876
|
},
|
|
2735
|
-
|
|
2736
|
-
}),
|
|
2737
|
-
// abort$ sits OUTSIDE retry, so it kills the entire retry loop,
|
|
2738
|
-
// not just the current attempt. destroy$ is the defense-in-depth
|
|
2739
|
-
// signal for injector teardown — if the abort action chain doesn't
|
|
2740
|
-
// run for any reason, destroy$ still stops the loop.
|
|
2741
|
-
takeUntil(merge(this.abort$, this.destroy$)));
|
|
2877
|
+
}));
|
|
2742
2878
|
}
|
|
2743
2879
|
startEstablishingConnection() {
|
|
2744
2880
|
this.showStatusMessage(UnrealStatusMessage.STARTING_YOUR_SESSION);
|
|
@@ -2753,177 +2889,117 @@ class SignallingService extends SubService {
|
|
|
2753
2889
|
});
|
|
2754
2890
|
}
|
|
2755
2891
|
/**
|
|
2756
|
-
*
|
|
2757
|
-
*
|
|
2758
|
-
*
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
*
|
|
2767
|
-
*
|
|
2768
|
-
*
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
*
|
|
2779
|
-
*
|
|
2780
|
-
*
|
|
2781
|
-
*
|
|
2782
|
-
*
|
|
2783
|
-
* 8. **Mapping to `AwsInstance`** — Converts the validated orchestration payload into:
|
|
2784
|
-
* - `wsUrl`: via `httpUrlToWs(\`\${location.protocol}//\${data.signallingServer}\`)`
|
|
2785
|
-
* - `pollingUrl`: the base `signalingUrl` that succeeded
|
|
2786
|
-
* - `instanceName`: the (validated) `data.signallingServer`
|
|
2787
|
-
* 9. **Retry policy** — On *any* upstream error (timeout, HTTP error, invalid payload),
|
|
2788
|
-
* `retry({ delay })`:
|
|
2789
|
-
* - Shows a “connecting” status,
|
|
2790
|
-
* - Logs the attempt number,
|
|
2791
|
-
* - Waits `WS_TIMEOUT` ms (via `timer`) and **resubscribes**, advancing `urlGen`.
|
|
2792
|
-
*
|
|
2793
|
-
* ### Concurrency semantics
|
|
2794
|
-
*
|
|
2795
|
-
* - `exhaustMap` guarantees a **single** HTTP request per attempt; later
|
|
2796
|
-
* `{clientId, viewId}` emissions are ignored until the current request completes.
|
|
2797
|
-
*
|
|
2798
|
-
* ### Telemetry semantics
|
|
2799
|
-
*
|
|
2800
|
-
* - `trackStartCommand('EXT-getSignaling')` begins before work.
|
|
2801
|
-
* - `trackStopCommand('getSignaling', {...})` runs on receipt of an orchestration response.
|
|
2802
|
-
* If an attempt fails (e.g., timeout), stopping the span is the responsibility of
|
|
2803
|
-
* your telemetry layer or a `finalize` elsewhere if desired.
|
|
2804
|
-
*
|
|
2805
|
-
* ### Error & retry semantics
|
|
2806
|
-
*
|
|
2807
|
-
* - Missing/never-emitted IDs → timeout → retry with next URL.
|
|
2808
|
-
* - HTTP/network error → retry with next URL.
|
|
2809
|
-
* - Invalid orchestration payload (`error` set or empty `signallingServer`) → throw in
|
|
2810
|
-
* validation filter → retry with next URL.
|
|
2811
|
-
* - Short-circuit WS path **does not** retry (emits once, completes).
|
|
2812
|
-
*
|
|
2813
|
-
* @param urlsPool - Ordered list of base URLs to probe. Retries advance through this list
|
|
2814
|
-
* until a working signaling server is found. A candidate may also be a direct `ws://`/`wss://`
|
|
2815
|
-
* URL to short-circuit HTTP orchestration.
|
|
2816
|
-
*
|
|
2817
|
-
* @returns Observable that emits exactly one {@link AwsInstance} on success and then completes.
|
|
2818
|
-
* On failure, the stream errors; the built-in `retry` operator re-subscribes after `WS_TIMEOUT`
|
|
2819
|
-
* and advances to the next URL candidate.
|
|
2820
|
-
*
|
|
2821
|
-
* @throws Emits an error within the observable chain (not a thrown synchronous exception) when:
|
|
2822
|
-
* - The HTTP request fails or times out.
|
|
2823
|
-
* - The orchestration response indicates an error or an empty `signallingServer`.
|
|
2824
|
-
* - Any other operator in the chain surfaces an error.
|
|
2825
|
-
*
|
|
2826
|
-
* @remarks
|
|
2827
|
-
* - The URL generator (`urlGen`) is created **outside** `defer`, so resubscriptions
|
|
2828
|
-
* advance the pool. Creating it inside `defer` would restart from the first URL each retry.
|
|
2829
|
-
* - `location.protocol` is used to derive `ws` vs. `wss`. If this may run in non-browser
|
|
2830
|
-
* contexts (e.g., SSR), guard or abstract this logic.
|
|
2831
|
-
* - Consider capping retries with `count` in `retry({ count, delay })` when appropriate.
|
|
2832
|
-
*
|
|
2833
|
-
* @example
|
|
2834
|
-
* ```ts
|
|
2835
|
-
* getAwsInstance(['https://signaling.example.com/', 'wss://direct.example.com'])
|
|
2836
|
-
* .subscribe({
|
|
2837
|
-
* next: ({ wsUrl }) => connect(wsUrl),
|
|
2838
|
-
* error: (e) => console.error('Unable to establish signaling:', e),
|
|
2839
|
-
* });
|
|
2840
|
-
* ```
|
|
2841
|
-
*
|
|
2842
|
-
* @see httpUrlToWs
|
|
2843
|
-
* @see selectClientAndViewIds
|
|
2844
|
-
* @see setStatusPercentSignallingServer
|
|
2892
|
+
* Resolve one URL to an `AwsInstance`. Dispatches to
|
|
2893
|
+
* {@link directWsInstance} for `ws://` / `wss://` URLs, otherwise
|
|
2894
|
+
* to {@link pollOrchestration}.
|
|
2895
|
+
*/
|
|
2896
|
+
getAwsInstance(url) {
|
|
2897
|
+
return url.match(/^ws(s?):\/\//i)
|
|
2898
|
+
? this.directWsInstance(url)
|
|
2899
|
+
: this.pollOrchestration(url);
|
|
2900
|
+
}
|
|
2901
|
+
/**
|
|
2902
|
+
* Direct `ws://` / `wss://` short-circuit — no HTTP orchestration.
|
|
2903
|
+
* Waits for `clientId` and `viewId` to be truthy in the store, then
|
|
2904
|
+
* emits a `Direct Connection` `AwsInstance` and completes.
|
|
2905
|
+
*/
|
|
2906
|
+
directWsInstance(url) {
|
|
2907
|
+
return this.store.select(selectClientAndViewIds).pipe(filter(({ clientId, viewId }) => !!clientId && !!viewId), take(1), map$1(() => ({
|
|
2908
|
+
wsUrl: url,
|
|
2909
|
+
pollingUrl: null,
|
|
2910
|
+
instanceName: 'Direct Connection',
|
|
2911
|
+
})));
|
|
2912
|
+
}
|
|
2913
|
+
/**
|
|
2914
|
+
* HTTP-orchestration path. Brackets {@link pollLoop} with the
|
|
2915
|
+
* `pollingStarted`/`pollingEnded` actions and the `EXT-getSignaling`
|
|
2916
|
+
* telemetry span (start in `defer`, stop in `finalize` with the
|
|
2917
|
+
* last response captured in the closure). Maps the final ready
|
|
2918
|
+
* response to an `AwsInstance`.
|
|
2845
2919
|
*/
|
|
2846
|
-
|
|
2847
|
-
this.commandTelemetryService.trackStartCommand('EXT-getSignaling');
|
|
2848
|
-
// Stateful iterator over candidate URLs; retries advance this sequence.
|
|
2849
|
-
const urlGen = getActiveUrl(urlsPool);
|
|
2920
|
+
pollOrchestration(url) {
|
|
2850
2921
|
return defer(() => {
|
|
2851
|
-
|
|
2852
|
-
// Short-circuit if the candidate is already a WS endpoint.
|
|
2853
|
-
if (signalingUrl.match(/^ws(s?):\/\//i)) {
|
|
2854
|
-
return this.store.select(selectClientAndViewIds).pipe(filter(({ clientId, viewId }) => !!clientId && !!viewId), map$1(() => {
|
|
2855
|
-
return {
|
|
2856
|
-
wsUrl: signalingUrl,
|
|
2857
|
-
pollingUrl: null,
|
|
2858
|
-
instanceName: 'Direct Connection',
|
|
2859
|
-
};
|
|
2860
|
-
}));
|
|
2861
|
-
}
|
|
2862
|
-
// Old (HTTP-polling) orchestration flow — mark the round trip so
|
|
2863
|
-
// the signaling-phase timeline can show it as a distinct segment.
|
|
2922
|
+
let lastResp;
|
|
2864
2923
|
this.store.dispatch(pollingStarted());
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
tap(({ clientId, viewId }) => {
|
|
2924
|
+
this.commandTelemetryService.trackStartCommand('EXT-getSignaling');
|
|
2925
|
+
return this.store.select(selectClientAndViewIds).pipe(tap(({ clientId, viewId }) => {
|
|
2868
2926
|
if (!clientId || !viewId) {
|
|
2869
2927
|
Logger.error('Client ID or View ID is not set');
|
|
2870
2928
|
}
|
|
2871
|
-
}),
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
timeout(POLLING_TIME),
|
|
2880
|
-
// Validate response; report progress; fail fast on invalid payloads.
|
|
2881
|
-
filter((data) => {
|
|
2882
|
-
this.commandTelemetryService.trackStopCommand('EXT-getSignaling', true, { ...data, multi: true });
|
|
2883
|
-
if (data.signallingServer === '' || data.error) {
|
|
2884
|
-
this.showStatusMessage(data?.mm_message || 'Server not found');
|
|
2885
|
-
if (data?.info) {
|
|
2886
|
-
this.store.dispatch(setStatusPercentSignallingServer({
|
|
2887
|
-
percent: data.info.percent,
|
|
2888
|
-
}));
|
|
2889
|
-
}
|
|
2890
|
-
// Throwing here fails the attempt and activates the retry strategy.
|
|
2891
|
-
throw new Error(`Orchestration message => ${data?.mm_message || 'Polling retry'}`);
|
|
2892
|
-
}
|
|
2893
|
-
return true;
|
|
2894
|
-
}),
|
|
2895
|
-
// Map a valid orchestration payload into the final AwsInstance.
|
|
2896
|
-
map$1((data) => {
|
|
2929
|
+
}), filter(({ clientId, viewId }) => !!clientId && !!viewId), take(1), switchMap(({ clientId, viewId }) => this.pollLoop(`${url}${clientId}/${viewId}`).pipe(tap((data) => {
|
|
2930
|
+
lastResp = data;
|
|
2931
|
+
}))), map$1((data) => ({
|
|
2932
|
+
wsUrl: httpUrlToWs(`${location.protocol}//${data.signallingServer}`),
|
|
2933
|
+
pollingUrl: url,
|
|
2934
|
+
instanceName: data.signallingServer ?? '',
|
|
2935
|
+
})), finalize(() => {
|
|
2936
|
+
this.commandTelemetryService.trackStopCommand('EXT-getSignaling', true, { ...(lastResp ?? {}), multi: true });
|
|
2897
2937
|
this.store.dispatch(pollingEnded());
|
|
2898
|
-
const wsUrl = httpUrlToWs(`${location.protocol}//${data.signallingServer}`);
|
|
2899
|
-
return {
|
|
2900
|
-
wsUrl,
|
|
2901
|
-
pollingUrl: signalingUrl,
|
|
2902
|
-
instanceName: data.signallingServer ?? '',
|
|
2903
|
-
};
|
|
2904
2938
|
}));
|
|
2905
|
-
})
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2941
|
+
/**
|
|
2942
|
+
* Poll the SAME `url` until ready, or until HTTP 4xx says the URL
|
|
2943
|
+
* is dead.
|
|
2944
|
+
*
|
|
2945
|
+
* | Response | Class | Action |
|
|
2946
|
+
* | ---------------------------------------- | ---------- | ---------------------------- |
|
|
2947
|
+
* | 200 `{ signallingServer: 'cirrus', ... }`| ✅ Ready | `first()` emits, loop ends |
|
|
2948
|
+
* | 200 empty `signallingServer` (any error/info/mm_message) | ⏳ Progress | Dispatch status+percent, re-poll same URL |
|
|
2949
|
+
* | HTTP 4xx (except 429) | ❌ Hard | Re-throw → caller rotates URL |
|
|
2950
|
+
* | HTTP 5xx / 429 / timeout / network | ⚠️ Soft | `EMPTY` → `repeat` same URL |
|
|
2951
|
+
*
|
|
2952
|
+
* `data.error` is NOT treated as fatal — the backend reuses it for
|
|
2953
|
+
* progress strings like `"Ready in ~2m 23s"`. The only fatal exits
|
|
2954
|
+
* are HTTP 4xx and `abort$` upstream.
|
|
2955
|
+
*/
|
|
2956
|
+
pollLoop(url) {
|
|
2957
|
+
return defer(() => this.httpClient.get(url)).pipe(timeout(POLLING_TIME), catchError((err) => {
|
|
2958
|
+
// HTTP 4xx (except 429) = the URL itself is dead → rotate.
|
|
2959
|
+
const status = err?.status;
|
|
2960
|
+
if (typeof status === 'number' &&
|
|
2961
|
+
status >= 400 &&
|
|
2962
|
+
status < 500 &&
|
|
2963
|
+
status !== 429) {
|
|
2964
|
+
Logger.warn(`Polling HARD error on ${url} (HTTP ${status}); rotating URL`);
|
|
2965
|
+
return throwError(() => err);
|
|
2966
|
+
}
|
|
2967
|
+
// Everything else = soft → re-poll same URL.
|
|
2968
|
+
Logger.warn(`Polling soft error on ${url} (${err?.message ?? err}); re-polling same URL`);
|
|
2969
|
+
return EMPTY;
|
|
2970
|
+
}), tap((data) => {
|
|
2971
|
+
// Ready response is handled by `first()` below — nothing to
|
|
2972
|
+
// dispatch here. Empty `signallingServer` = progress.
|
|
2973
|
+
if (data.signallingServer)
|
|
2974
|
+
return;
|
|
2975
|
+
this.showStatusMessage(data?.mm_message || 'Server not found');
|
|
2976
|
+
if (data?.info) {
|
|
2977
|
+
this.store.dispatch(setStatusPercentSignallingServer({
|
|
2978
|
+
percent: data.info.percent,
|
|
2913
2979
|
}));
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2980
|
+
}
|
|
2981
|
+
}),
|
|
2982
|
+
// Re-subscribe (= poll again) after each completed response OR
|
|
2983
|
+
// after `catchError` swallowed a transient failure.
|
|
2984
|
+
repeat({ delay: () => timer(POLLING_TIME) }),
|
|
2985
|
+
// Stop the poll loop as soon as we get a ready response.
|
|
2986
|
+
first((data) => !!data.signallingServer));
|
|
2920
2987
|
}
|
|
2921
2988
|
connectToCirrus(wsUrl) {
|
|
2922
|
-
|
|
2923
|
-
|
|
2989
|
+
// Wrapped in `defer` so each subscription (including ws-retry
|
|
2990
|
+
// re-subscriptions from `connectAttempt`) starts a FRESH WS attempt:
|
|
2991
|
+
// the previous socket is closed and a new one is created. Without
|
|
2992
|
+
// this, the WS-only retry would just re-subscribe the SAME WS
|
|
2993
|
+
// observable, which in production-defer'd `createWebSocket` would
|
|
2994
|
+
// still create a new socket, but in tests would re-emit the same
|
|
2995
|
+
// (already-errored) mock observable.
|
|
2996
|
+
return defer(() => {
|
|
2997
|
+
this.close({ code: 1000, reason: DisconnectReason.NewConnectionAttempt });
|
|
2998
|
+
return createWebSocket(wsUrl);
|
|
2999
|
+
}).pipe(takeUntil(this.abort$), tapLog('WS CREATED'), tap((ws) => {
|
|
2924
3000
|
this.commandTelemetryService.trackStartCommand('EXT-iceCandidate');
|
|
2925
3001
|
this.addWsHandlers(ws);
|
|
2926
|
-
})
|
|
3002
|
+
}));
|
|
2927
3003
|
}
|
|
2928
3004
|
addWsHandlers(ws) {
|
|
2929
3005
|
ws.onmessage = OnMessageHandler.bind(this);
|
|
@@ -2942,9 +3018,6 @@ class SignallingService extends SubService {
|
|
|
2942
3018
|
*/
|
|
2943
3019
|
close(data) {
|
|
2944
3020
|
const { code, reason } = data;
|
|
2945
|
-
// Signal "no answer in flight anymore" to any subscriber that observes
|
|
2946
|
-
// raw emissions (today's subscribers filter Truthy, but the Subject's
|
|
2947
|
-
// `| null` type is the contract — keep it honored).
|
|
2948
3021
|
this.onWebRtcAnswer$.next(null);
|
|
2949
3022
|
this.orchestrationRetrySub?.unsubscribe();
|
|
2950
3023
|
if (this.ws) {
|
|
@@ -4046,25 +4119,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
4046
4119
|
}]
|
|
4047
4120
|
}] });
|
|
4048
4121
|
|
|
4049
|
-
class UnrealCommunicatorService {
|
|
4122
|
+
class UnrealCommunicatorService extends SubService {
|
|
4050
4123
|
constructor() {
|
|
4051
|
-
|
|
4052
|
-
this.actions$ = inject(Actions);
|
|
4124
|
+
super();
|
|
4053
4125
|
this.commandTelemetryService = inject(CommandTelemetryService);
|
|
4054
4126
|
this.webRtcPlayerService = inject(WebRtcPlayerService);
|
|
4055
4127
|
this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
|
|
4056
|
-
this.destroyRef = inject(DestroyRef);
|
|
4057
|
-
this.destroy$ = new Subject();
|
|
4058
4128
|
this.cirrusConnected = this.store.selectSignal(unrealFeature.selectCirrusConnected);
|
|
4059
4129
|
this.afkIgnoredCommands = new Set(['executeConsoleCommand']);
|
|
4060
4130
|
this.skipAfkReset = false;
|
|
4061
4131
|
// Throttle: input events fire 60-200x/sec, but AFK timeouts are measured in
|
|
4062
4132
|
// seconds. Leading-edge throttle dispatches once, then suppresses for 1s.
|
|
4063
4133
|
this.resetAfk$ = new Subject();
|
|
4064
|
-
this.destroyRef.onDestroy(() => {
|
|
4065
|
-
this.destroy$.next();
|
|
4066
|
-
this.destroy$.complete();
|
|
4067
|
-
});
|
|
4068
4134
|
this.resetAfk$
|
|
4069
4135
|
.pipe(throttleTime(1000), takeUntil$1(this.destroy$))
|
|
4070
4136
|
.subscribe(() => this.store.dispatch(resetAfk()));
|
|
@@ -4670,20 +4736,21 @@ class RegionsPingService {
|
|
|
4670
4736
|
constructor() {
|
|
4671
4737
|
this.httpClient = inject(HttpClient);
|
|
4672
4738
|
this.store = inject(Store);
|
|
4673
|
-
this.
|
|
4739
|
+
this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
|
|
4674
4740
|
this.destroyRef = inject(DestroyRef);
|
|
4675
|
-
// Aborted
|
|
4676
|
-
//
|
|
4677
|
-
|
|
4741
|
+
// Aborted when the injector is destroyed. Forwarded into every
|
|
4742
|
+
// `startPinging` call's winnerController so in-flight fetches cancel
|
|
4743
|
+
// immediately on route exit instead of running to natural completion.
|
|
4744
|
+
this.destroyController = new AbortController();
|
|
4678
4745
|
this.config = {
|
|
4679
4746
|
ping_count: 2,
|
|
4680
4747
|
ping_timeout: 2000,
|
|
4681
4748
|
max_parallel_requests: 10,
|
|
4682
4749
|
bust_cache: true,
|
|
4683
4750
|
};
|
|
4684
|
-
this.destroyRef.onDestroy(() => this.
|
|
4751
|
+
this.destroyRef.onDestroy(() => this.destroyController.abort());
|
|
4685
4752
|
}
|
|
4686
|
-
getFastest(regionListUrl = this.
|
|
4753
|
+
getFastest(regionListUrl = this.unrealConfig?.regionsPingUrl || '') {
|
|
4687
4754
|
// Self-announce every lifecycle waypoint of the region-discovery
|
|
4688
4755
|
// flow (start / providers-fetched / resolved). The final
|
|
4689
4756
|
// `regionResolved` forwards the winner's full ping-sample array
|
|
@@ -4700,31 +4767,34 @@ class RegionsPingService {
|
|
|
4700
4767
|
const defaultTimeout = providers.timeout ?? this.config.ping_timeout;
|
|
4701
4768
|
return from(this.startPinging(providers.regions, defaultTimeout));
|
|
4702
4769
|
}
|
|
4703
|
-
getProviders(regionListUrl = this.
|
|
4770
|
+
getProviders(regionListUrl = this.unrealConfig?.regionsPingUrl || '') {
|
|
4704
4771
|
return this.httpClient.get(regionListUrl);
|
|
4705
4772
|
}
|
|
4706
4773
|
async startPinging(endpoints, defaultTimeout) {
|
|
4707
|
-
// Two-tier cancellation:
|
|
4708
|
-
// - destroyAbort: service teardown (injector destroy)
|
|
4709
|
-
// - winnerAbort: first successful region resolves Promise.any
|
|
4710
|
-
// Both aborts cascade into every in-flight fetch so losing pings stop
|
|
4711
|
-
// immediately instead of running their full ping_count × timeout budget.
|
|
4712
|
-
const winnerAbort = new AbortController();
|
|
4713
|
-
const stopSignal = AbortSignal.any([
|
|
4714
|
-
winnerAbort.signal,
|
|
4715
|
-
this.destroyAbort.signal,
|
|
4716
|
-
]);
|
|
4717
4774
|
const semaphore = new Semaphore(this.config.max_parallel_requests);
|
|
4775
|
+
// Shared "stop" signal. Aborted when EITHER:
|
|
4776
|
+
// - one region finishes successfully (winner found → cancel losers); OR
|
|
4777
|
+
// - the service's injector is destroyed (route exit → cancel everything).
|
|
4778
|
+
// Without this, losing regions keep pinging to their own `ping_timeout`,
|
|
4779
|
+
// generating up to `(endpoints.length - 1) × ping_count` wasted requests
|
|
4780
|
+
// every time `getFastest` runs, and in-flight fetches outlive the route.
|
|
4781
|
+
const winnerController = new AbortController();
|
|
4782
|
+
const destroySignal = this.destroyController.signal;
|
|
4783
|
+
let onDestroyAbort;
|
|
4784
|
+
if (destroySignal.aborted) {
|
|
4785
|
+
// Service already destroyed — bail every task synchronously.
|
|
4786
|
+
winnerController.abort(destroySignal.reason);
|
|
4787
|
+
}
|
|
4788
|
+
else {
|
|
4789
|
+
onDestroyAbort = () => winnerController.abort(destroySignal.reason);
|
|
4790
|
+
destroySignal.addEventListener('abort', onDestroyAbort, { once: true });
|
|
4791
|
+
}
|
|
4718
4792
|
const tasks = endpoints.map(async ({ region_code, url, timeout }) => {
|
|
4719
4793
|
await semaphore.acquire();
|
|
4720
4794
|
try {
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
}
|
|
4724
|
-
const result = await this.checkLatency(url, timeout ?? defaultTimeout, stopSignal);
|
|
4725
|
-
if (result.length < 2) {
|
|
4795
|
+
const result = await this.checkLatency(url, timeout ?? defaultTimeout, winnerController.signal);
|
|
4796
|
+
if (result.length < 2)
|
|
4726
4797
|
throw new Error('insufficient pings');
|
|
4727
|
-
}
|
|
4728
4798
|
return { region_code, result };
|
|
4729
4799
|
}
|
|
4730
4800
|
finally {
|
|
@@ -4732,18 +4802,31 @@ class RegionsPingService {
|
|
|
4732
4802
|
}
|
|
4733
4803
|
});
|
|
4734
4804
|
try {
|
|
4735
|
-
|
|
4736
|
-
winnerAbort.abort();
|
|
4737
|
-
return winner;
|
|
4805
|
+
return await Promise.any(tasks);
|
|
4738
4806
|
}
|
|
4739
4807
|
catch {
|
|
4740
4808
|
return null;
|
|
4741
4809
|
}
|
|
4810
|
+
finally {
|
|
4811
|
+
// Cancel losing in-flight pings. Safe to call on success (we already
|
|
4812
|
+
// captured the winner) AND on all-failed (no-op, all tasks settled).
|
|
4813
|
+
winnerController.abort();
|
|
4814
|
+
// Detach the destroy listener so it doesn't linger on the long-lived
|
|
4815
|
+
// destroyController.signal across multiple `getFastest` invocations.
|
|
4816
|
+
if (onDestroyAbort) {
|
|
4817
|
+
destroySignal.removeEventListener('abort', onDestroyAbort);
|
|
4818
|
+
}
|
|
4819
|
+
}
|
|
4742
4820
|
}
|
|
4743
|
-
async checkLatency(endpoint, timeout,
|
|
4821
|
+
async checkLatency(endpoint, timeout, abortSignal) {
|
|
4744
4822
|
const pingResults = [];
|
|
4745
4823
|
const sep = endpoint.includes('?') ? '&' : '?';
|
|
4746
4824
|
for (let i = 0; i < this.config.ping_count; i++) {
|
|
4825
|
+
// Bail out as soon as another region's task has won — no point
|
|
4826
|
+
// burning further `ping_timeout` budget here.
|
|
4827
|
+
if (abortSignal?.aborted) {
|
|
4828
|
+
break;
|
|
4829
|
+
}
|
|
4747
4830
|
const startTime = performance.now();
|
|
4748
4831
|
try {
|
|
4749
4832
|
await this.fetchWithTimeout(this.config.bust_cache
|
|
@@ -4757,7 +4840,7 @@ class RegionsPingService {
|
|
|
4757
4840
|
Expires: '0',
|
|
4758
4841
|
},
|
|
4759
4842
|
credentials: 'omit',
|
|
4760
|
-
}, timeout,
|
|
4843
|
+
}, timeout, abortSignal);
|
|
4761
4844
|
}
|
|
4762
4845
|
catch {
|
|
4763
4846
|
// intentional: finally pushes the elapsed time as the latency sample
|
|
@@ -4769,20 +4852,31 @@ class RegionsPingService {
|
|
|
4769
4852
|
}
|
|
4770
4853
|
return pingResults;
|
|
4771
4854
|
}
|
|
4772
|
-
async fetchWithTimeout(url, options = {}, timeout,
|
|
4855
|
+
async fetchWithTimeout(url, options = {}, timeout, abortSignal) {
|
|
4773
4856
|
const controller = new AbortController();
|
|
4774
|
-
options.signal =
|
|
4775
|
-
|
|
4776
|
-
|
|
4857
|
+
options.signal = controller.signal;
|
|
4858
|
+
// Forward an external abort into our local controller so a winner-found signal cancels the in-flight `fetch` immediately. If the
|
|
4859
|
+
// external signal is already aborted, abort synchronously before
|
|
4860
|
+
// even starting the fetch.
|
|
4861
|
+
let onExternalAbort;
|
|
4862
|
+
if (abortSignal) {
|
|
4863
|
+
if (abortSignal.aborted) {
|
|
4864
|
+
controller.abort(abortSignal.reason);
|
|
4865
|
+
}
|
|
4866
|
+
else {
|
|
4867
|
+
onExternalAbort = () => controller.abort(abortSignal.reason);
|
|
4868
|
+
abortSignal.addEventListener('abort', onExternalAbort, { once: true });
|
|
4869
|
+
}
|
|
4870
|
+
}
|
|
4777
4871
|
const timeoutId = setTimeout(() => controller.abort(new DOMException('Request timed out', 'TimeoutError')), timeout);
|
|
4778
4872
|
try {
|
|
4779
|
-
|
|
4780
|
-
clearTimeout(timeoutId);
|
|
4781
|
-
return response;
|
|
4873
|
+
return await fetch(url, options);
|
|
4782
4874
|
}
|
|
4783
|
-
|
|
4875
|
+
finally {
|
|
4784
4876
|
clearTimeout(timeoutId);
|
|
4785
|
-
|
|
4877
|
+
if (abortSignal && onExternalAbort) {
|
|
4878
|
+
abortSignal.removeEventListener('abort', onExternalAbort);
|
|
4879
|
+
}
|
|
4786
4880
|
}
|
|
4787
4881
|
}
|
|
4788
4882
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: RegionsPingService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
@@ -7085,5 +7179,5 @@ function provideUnrealModule() {
|
|
|
7085
7179
|
* Generated bundle index. Do not edit.
|
|
7086
7180
|
*/
|
|
7087
7181
|
|
|
7088
|
-
export { AFKService, AfkTimeoutModalComponent, AggregatorService, AnalyticsService, AnswerHandler, CONSOLE_COMMAND_DISABLE_MESSAGES, CONSOLE_COMMAND_ENABLE_MESSAGES, CONSOLE_COMMAND_PIXEL_QUALITY, CommandTelemetryService, ConfigHandler, ConsoleExtensionsService, ControlScheme, DATA_CHANNEL_CONNECTION_TIMEOUT, DEBOUNCE_TO_MANY_RESIZE_CALLS, DEFAULT_AFK_TIMEOUT, DEFAULT_AFK_TIMEOUT_PERIOD, DEFAULT_RECONNECT_DELAY_MS, DEFAULT_RECONNECT_ENABLED, DEFAULT_RECONNECT_MAX_ATTEMPTS, DEFAULT_RECONNECT_ON_DATACHANNEL_CLOSE, DEFAULT_RECONNECT_ON_ICE_FAILURE, DESTROY_DISCONNECT_REASONS, DataFlowMonitor, DevModeService, DisconnectReason, FULL_HD_HEIGHT, FULL_HD_WIDTH, FileHandlerService, FileReceiverService, FilterSettingsComponent, FpsMonitorService, FreezeFrameComponent, FreezeFrameService, IceCandidateHandler, ImageLoadingSrcComponent, InputOptions, InputService, InstanceReadyHandler, InstanceReservedHandler, IntroSrcComponent, KalmanFilter1D, LatencyTimings, LoadingProgressService, LowBandwidthDetectorComponent, LowBandwidthModalComponent, MINIMAL_FPS, MIN_CONTAINER_SIZE, MessageType, MouseButton, MouseButtonsMask, OnCloseHandler, OnErrorHandler, OnMessageHandler, OnOpenHandler, OrchestrationMessageTypes, POLLING_TIME, PingHandler, PlayerCountHandler, RegionsPingService, SAME_SIZE_THRESHOLD, SCREEN_LOCKER_CONTAINER_ID, SIGNALLING_PERCENT_VALUE, SSInfoHandler, STREAMING_AUDIO_ID, STREAMING_VIDEO_ID, SafePipe, SignallingService, SpecialKeyCodes, StatGraphComponent, StreamStatusTelemetryService, StreamStatusTelemetryService2, SubService, ToClientMessage, UNREAL_CONFIG, UnrealCallbackService, UnrealCommunicatorService, UnrealEffects, UnrealSceneComponent, UnrealStatusMessage, VideoService, VideoStatsComponent, WSCloseCode_CIRRUS_ABNORMAL_CLOSURE, WSCloseCode_CIRRUS_MAX_PLAYERS_ERROR, WSCloseCode_CIRRUS_PLAYER_DISCONNECTED, WSCloseCode_CIRRUS_STREAMER_KIKED_PLAYER, WSCloseCode_FORCE_CIRRUS_CLOSE, WSCloseCode_NORMAL_AFK_TIMEOUT, WSCloseCode_NORMAL_CLOSURE, WSCloseCode_NORMAL_MANUAL_DISCONNECT, WSCloseCode_UNKNOWN, WSCloseCodes, WS_OPEN_STATE, WS_TIMEOUT, WebRtcPlayerService, WebrtcErrorModalComponent, abortEstablishingConnection, answerSent, changeStatusMainVideoOnScene, changeStreamResolutionAction, commandCompleted, commandStarted, dataChannelConnected, dataChannelReady, decodeData, destroyRemoteConnections, destroyUnrealScene, disconnectStream, dispatchResize, dropConnection, dropDC, dropPC, floatToPercents, floatToSmoothPercents, forceResizeViewport, fromResizeObserver, getActiveUrl, getImageFromVideoStream, getResizeStudioValues, getResizeValuesUniversal, getRtcErrorMessage, iceConnectionFailed, initSignalling, instanceReady, keepMaxUntilReset, makeEven, pollingEnded, pollingStarted, provideUnrealModule, qpToQuality, reconnectPeer, reconnectPeerFailed, reconnectPeerSuccess, regionPingFetchEnd, regionPingFetchStart, regionResolved, resetAfk, resetConfig, resetDataChannelForReconnect, resetIntroSrc, resetUserInactivityTimeout, resetWarnTimeout, saveAnalyticsEvent, selectCirrusConnectionId, selectClientAndViewIds, selectCommandProgress, selectFreezeFrameCombinedDataUrl, selectFreezeFrameDataUrl, selectFreezeFrameDataUrlFromVideo, selectFreezeFrameProgressMessageFromVideo, selectIsAutostart, selectIsExistMatchUrls, selectIsFreezeFrameLoading, selectIsFullyConnected, selectIsLoaderScreenVisible, selectIsVideoPlayingAndDataChannelConnected, selectShowLoader, selectShowReconnectPopup, selectSignalingParameters, selectStreamConfig, selectTotalProgress, selectUserInactivityDetected, selectWarnTimeout, setAfkTimerCountdown, setAfkTimerHide, setAfkTimerVisible, setAwsInstance, setCirrusConnected, setCirrusDisconnected, setConfig, setEstablishingConnection, setFreezeFrame, setFreezeFrameFromVideo, setIntroImageSrc, setIntroVideoSrc, setLoadingImageSrc, setLoopBackCommandIsCompleted, setMaxFps, setOrchestrationContext, setOrchestrationMessage, setOrchestrationParameters, setOrchestrationProgress, setSignalingName, setStatusMessage, setStatusPercentSignallingServer, setStreamClientCompanyId, setStreamViewId, setUnrealPlaywrightConfig, setUserInactivityTimeout, setViewportNotReady, setViewportReady, showLoadingProgress, showResumeStreamPopup, showUnrealErrorMessage, showUserInactivityTimeoutPopup, signalingRetryAttempted, smoothTransition, startStream, trackMixpanelEvent, trackTelemetryData, unrealCommandCallback, unrealFeature, unrealReducer, updateCirrusInfo, videoAdaptedToContainer, videoStreamInitialized };
|
|
7182
|
+
export { AFKService, AfkTimeoutModalComponent, AggregatorService, AnalyticsService, AnswerHandler, CONSOLE_COMMAND_DISABLE_MESSAGES, CONSOLE_COMMAND_ENABLE_MESSAGES, CONSOLE_COMMAND_PIXEL_QUALITY, CommandTelemetryService, ConfigHandler, ConsoleExtensionsService, ControlScheme, DATA_CHANNEL_CONNECTION_TIMEOUT, DEBOUNCE_TO_MANY_RESIZE_CALLS, DEFAULT_AFK_TIMEOUT, DEFAULT_AFK_TIMEOUT_PERIOD, DEFAULT_RECONNECT_DELAY_MS, DEFAULT_RECONNECT_ENABLED, DEFAULT_RECONNECT_MAX_ATTEMPTS, DEFAULT_RECONNECT_ON_DATACHANNEL_CLOSE, DEFAULT_RECONNECT_ON_ICE_FAILURE, DESTROY_DISCONNECT_REASONS, DataFlowMonitor, DevModeService, DisconnectReason, FULL_HD_HEIGHT, FULL_HD_WIDTH, FileHandlerService, FileReceiverService, FilterSettingsComponent, FpsMonitorService, FreezeFrameComponent, FreezeFrameService, INNER_RETRY_LIMIT, IceCandidateHandler, ImageLoadingSrcComponent, InputOptions, InputService, InstanceReadyHandler, InstanceReservedHandler, IntroSrcComponent, KalmanFilter1D, LatencyTimings, LoadingProgressService, LowBandwidthDetectorComponent, LowBandwidthModalComponent, MINIMAL_FPS, MIN_CONTAINER_SIZE, MessageType, MouseButton, MouseButtonsMask, OUTER_RETRY_DELAY, OnCloseHandler, OnErrorHandler, OnMessageHandler, OnOpenHandler, OrchestrationMessageTypes, POLLING_TIME, PingHandler, PlayerCountHandler, RegionsPingService, SAME_SIZE_THRESHOLD, SCREEN_LOCKER_CONTAINER_ID, SIGNALLING_PERCENT_VALUE, SSInfoHandler, STREAMING_AUDIO_ID, STREAMING_VIDEO_ID, SafePipe, SignallingService, SpecialKeyCodes, StatGraphComponent, StreamStatusTelemetryService, StreamStatusTelemetryService2, SubService, ToClientMessage, UNREAL_CONFIG, UnrealCallbackService, UnrealCommunicatorService, UnrealEffects, UnrealSceneComponent, UnrealStatusMessage, VideoService, VideoStatsComponent, WSCloseCode_CIRRUS_ABNORMAL_CLOSURE, WSCloseCode_CIRRUS_MAX_PLAYERS_ERROR, WSCloseCode_CIRRUS_PLAYER_DISCONNECTED, WSCloseCode_CIRRUS_STREAMER_KIKED_PLAYER, WSCloseCode_FORCE_CIRRUS_CLOSE, WSCloseCode_NORMAL_AFK_TIMEOUT, WSCloseCode_NORMAL_CLOSURE, WSCloseCode_NORMAL_MANUAL_DISCONNECT, WSCloseCode_UNKNOWN, WSCloseCodes, WS_OPEN_STATE, WS_RETRY_PER_INSTANCE, WS_TIMEOUT, WebRtcPlayerService, WebrtcErrorModalComponent, abortEstablishingConnection, answerSent, changeStatusMainVideoOnScene, changeStreamResolutionAction, commandCompleted, commandStarted, dataChannelConnected, dataChannelReady, decodeData, destroyRemoteConnections, destroyUnrealScene, disconnectStream, dispatchResize, dropConnection, dropDC, dropPC, floatToPercents, floatToSmoothPercents, forceResizeViewport, fromResizeObserver, getActiveUrl, getImageFromVideoStream, getResizeStudioValues, getResizeValuesUniversal, getRtcErrorMessage, iceConnectionFailed, initSignalling, instanceReady, keepMaxUntilReset, makeEven, pollingEnded, pollingStarted, provideUnrealModule, qpToQuality, reconnectPeer, reconnectPeerFailed, reconnectPeerSuccess, regionPingFetchEnd, regionPingFetchStart, regionResolved, resetAfk, resetConfig, resetDataChannelForReconnect, resetIntroSrc, resetUserInactivityTimeout, resetWarnTimeout, saveAnalyticsEvent, selectCirrusConnectionId, selectClientAndViewIds, selectCommandProgress, selectFreezeFrameCombinedDataUrl, selectFreezeFrameDataUrl, selectFreezeFrameDataUrlFromVideo, selectFreezeFrameProgressMessageFromVideo, selectIsAutostart, selectIsExistMatchUrls, selectIsFreezeFrameLoading, selectIsFullyConnected, selectIsLoaderScreenVisible, selectIsVideoPlayingAndDataChannelConnected, selectShowLoader, selectShowReconnectPopup, selectSignalingParameters, selectStreamConfig, selectTotalProgress, selectUserInactivityDetected, selectWarnTimeout, setAfkTimerCountdown, setAfkTimerHide, setAfkTimerVisible, setAwsInstance, setCirrusConnected, setCirrusDisconnected, setConfig, setEstablishingConnection, setFreezeFrame, setFreezeFrameFromVideo, setIntroImageSrc, setIntroVideoSrc, setLoadingImageSrc, setLoopBackCommandIsCompleted, setMaxFps, setOrchestrationContext, setOrchestrationMessage, setOrchestrationParameters, setOrchestrationProgress, setSignalingName, setStatusMessage, setStatusPercentSignallingServer, setStreamClientCompanyId, setStreamViewId, setUnrealPlaywrightConfig, setUserInactivityTimeout, setViewportNotReady, setViewportReady, showLoadingProgress, showResumeStreamPopup, showUnrealErrorMessage, showUserInactivityTimeoutPopup, signalingRetryAttempted, smoothTransition, startStream, trackMixpanelEvent, trackTelemetryData, unrealCommandCallback, unrealFeature, unrealReducer, updateCirrusInfo, videoAdaptedToContainer, videoStreamInitialized };
|
|
7089
7183
|
//# sourceMappingURL=3dsource-angular-unreal-module.mjs.map
|