@3dsource/angular-unreal-module 0.0.162 → 0.0.164

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.
@@ -157,7 +157,6 @@ export interface RequestReservation extends InstanceMessageBase {
157
157
  }
158
158
  */
159
159
  const OrchestrationMessageTypes = {
160
- ping: 'ping',
161
160
  error: 'error',
162
161
  offer: 'offer',
163
162
  answer: 'answer',
@@ -171,8 +170,6 @@ const OrchestrationMessageTypes = {
171
170
  instanceReady: 'instanceReady',
172
171
  p2pEstablished: 'p2pEstablished',
173
172
  instanceReserved: 'instanceReserved',
174
- playerDisconnected: 'playerDisconnected',
175
- streamerDisconnected: 'streamerDisconnected',
176
173
  interruptClientStream: 'interruptClientStream',
177
174
  };
178
175
 
@@ -225,6 +222,14 @@ const OUTER_RETRY_DELAY = POLLING_TIME * 2;
225
222
  // blip, brief unavailability) without burning a second reservation.
226
223
  // Total WS attempts before URL rotation = 1 + WS_RETRY_PER_INSTANCE.
227
224
  const WS_RETRY_PER_INSTANCE = 1;
225
+ // Bound on `requestCirrusViaSocket`: how long the orchestration control
226
+ // socket may stay silent between `requestStream` and `instanceReady`.
227
+ // The orchestration itself reports an `eta` (default 150 s in
228
+ // `InstanceReservedHandler`) for a cold machine, so `WS_TIMEOUT` (6 s) is
229
+ // far too short here — this is a provisioning wait, not a handshake.
230
+ // On expiry the request fails like any other, so `connectAttempt`'s
231
+ // URL-rotating tier asks a different orchestration URL for a new machine.
232
+ const INSTANCE_READY_TIMEOUT = 180_000;
228
233
  const WS_OPEN_STATE = 1;
229
234
  const DEFAULT_AFK_TIMEOUT_PERIOD = 15;
230
235
  const DEFAULT_AFK_TIMEOUT = 300; // 5 minutes
@@ -479,7 +484,7 @@ const initialState = {
479
484
  };
480
485
  const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScene, (state, { isVideoPlaying }) => {
481
486
  return { ...state, isVideoPlaying };
482
- }), on(setAwsInstance, (state, { instanceName, wsUrl, pollingUrl }) => {
487
+ }), on(setAwsInstance, (state, { instanceName, wsUrl, pollingUrl, streamRequestId, kind }) => {
483
488
  return {
484
489
  ...state,
485
490
  awsInstance: {
@@ -487,7 +492,12 @@ const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScen
487
492
  instanceName,
488
493
  wsUrl,
489
494
  pollingUrl,
490
- progressComplete: 0,
495
+ streamRequestId,
496
+ kind: kind ?? state.awsInstance.kind,
497
+ // In the socket flow this action lands only once `instanceReady`
498
+ // arrived, i.e. the machine IS provisioned — resetting to 0 here would
499
+ // throw the bar back to the start right at the end of that phase.
500
+ progressComplete: kind === 'socket' ? 1 : 0,
491
501
  },
492
502
  };
493
503
  }), on(setViewportReady, (state) => {
@@ -593,6 +603,10 @@ const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScen
593
603
  instanceName,
594
604
  streamRequestId,
595
605
  eta,
606
+ // `instanceReserved` exists only in the socket flow, and it arrives
607
+ // BEFORE `setAwsInstance` — so this is where the flow becomes known,
608
+ // early enough for `selectTotalProgress` to use the eta-driven bar.
609
+ kind: 'socket',
596
610
  },
597
611
  };
598
612
  }), on(setOrchestrationProgress, (state, { progressComplete }) => {
@@ -642,7 +656,7 @@ const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScen
642
656
  return { ...state, analyticsEvent: event };
643
657
  }), on(setCirrusConnected, (state) => {
644
658
  return { ...state, cirrusConnected: true };
645
- }), on(streamAdopted, (state, { machineId, ssData }) => {
659
+ }), on(streamAdopted, (state, { machineId, ssData, streamRequestId }) => {
646
660
  return {
647
661
  ...state,
648
662
  cirrusConnected: true,
@@ -653,6 +667,7 @@ const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScen
653
667
  wsUrl: machineId,
654
668
  pollingUrl: null,
655
669
  instanceName: 'Adopted Connection',
670
+ streamRequestId: streamRequestId ?? state.awsInstance.streamRequestId,
656
671
  progressComplete: 0,
657
672
  },
658
673
  };
@@ -778,12 +793,16 @@ function InstanceReadyHandler(msg) {
778
793
  Logger.warn('instanceReady received without payload — ignoring');
779
794
  return;
780
795
  }
796
+ if (!payload.originInstanceUri) {
797
+ Logger.warn('instanceReady without originInstanceUri — ignoring');
798
+ return;
799
+ }
781
800
  // Emit for observability (telemetry) and store the stream-machine coords.
782
801
  this.store.dispatch(instanceReady(payload));
783
- // Open the SECOND socket directly to the stream machine and route the whole
784
- // WebRTC signalling through it. The orchestration socket stays open for
785
- // control/lifecycle.
786
- this.connectToStreamMachine(payload.originInstanceUri);
802
+ // This is the answer `requestCirrusViaSocket` is waiting for: the Cirrus
803
+ // address (`originInstanceUri`) plus the connection id to hand it. The socket
804
+ // itself is opened by the shared `connectToCirrus`, not here.
805
+ this.onInstanceReady$.next(payload);
787
806
  }
788
807
 
789
808
  function InstanceReservedHandler(msg) {
@@ -794,15 +813,6 @@ function InstanceReservedHandler(msg) {
794
813
  }));
795
814
  }
796
815
 
797
- function PingHandler() {
798
- // Reply pong on the active signalling socket (stream socket in the new flow,
799
- // relay socket in the old polling flow).
800
- this.sendPlayerSignal(JSON.stringify({
801
- type: 'pong',
802
- source: 'front',
803
- }));
804
- }
805
-
806
816
  function PlayerCountHandler(msg) {
807
817
  Logger.log(msg);
808
818
  }
@@ -858,9 +868,16 @@ function OnMessageHandler(e) {
858
868
  }
859
869
  }
860
870
 
871
+ /**
872
+ * Open handler of the CIRRUS socket only (`addWsHandlers`), so
873
+ * `setCirrusConnected` now means what it says. It used to also send
874
+ * `requestStream` here, which made the flag fire on the orchestration control
875
+ * socket — a whole provisioning wait (`eta`, ~150 s) before any Cirrus existed.
876
+ * `requestStream` belongs to the control socket and is sent by
877
+ * `requestCirrusViaSocket`.
878
+ */
861
879
  function OnOpenHandler() {
862
880
  this.store.dispatch(setCirrusConnected());
863
- this.sendRequestStream();
864
881
  }
865
882
 
866
883
  /**
@@ -1180,7 +1197,9 @@ const selectSignalingParameters = createSelector(unrealFeature.selectSsData, unr
1180
1197
  info.push(`Unreal App Version: ${data.streamerVersion}`);
1181
1198
  info.push(`APP: ${data.streamPath}${data.streamApp}`);
1182
1199
  }
1183
- info.push(`Polling: ${instance.pollingUrl}`);
1200
+ // The endpoint that handed us the Cirrus address: an HTTP polling URL in one
1201
+ // flow, a `wss://` orchestration URL in the other.
1202
+ info.push(`Orchestration: ${instance.pollingUrl}`);
1184
1203
  info.push(`WS: ${instance.wsUrl}`);
1185
1204
  return info.join('<br>');
1186
1205
  });
@@ -1200,13 +1219,15 @@ const selectIsLoaderScreenVisible = createSelector(selectIsFullyConnected, (full
1200
1219
  */
1201
1220
  const selectTotalProgress = createSelector(unrealFeature.selectStatusPercentSignallingServer, selectCommandProgress, unrealFeature.selectAwsInstance, unrealFeature.selectDataChannelConnected, (statusPercentSignallingServer, commandProgress, awsInstance, dataChannelConnected) => {
1202
1221
  const SIGNALLING_MAX = SIGNALLING_PERCENT_VALUE / 100;
1203
- const ORCHESTRATION_WS_PATTERN = /^wss:\/\/orchestration-websocket/i;
1204
- const isNewOrchestration = ORCHESTRATION_WS_PATTERN.test(awsInstance?.wsUrl ?? '');
1222
+ // Stated by the service (`AwsInstance.kind`), not sniffed from the hostname.
1223
+ // In the socket flow it is set by `instanceReserved` — i.e. before
1224
+ // `setAwsInstance` — so the eta-driven bar is picked up from the start of
1225
+ // the provisioning wait.
1226
+ const isNewOrchestration = awsInstance?.kind === 'socket';
1205
1227
  // Once the data channel is connected, signaling is fully complete (1.0) —
1206
1228
  // regardless of orchestration type. This is what makes an ADOPTED stream
1207
- // (whose wsUrl is the machine host, not the orchestration-websocket URL,
1208
- // and which never set `statusPercentSignallingServer`) correctly show the
1209
- // whole first phase done instead of stalling near the command-only value.
1229
+ // (which never set `statusPercentSignallingServer`) correctly show the whole
1230
+ // first phase done instead of stalling near the command-only value.
1210
1231
  // Otherwise: new orchestration reports 0–1 progress; legacy uses the
1211
1232
  // polling percent.
1212
1233
  const signalingProgress = dataChannelConnected
@@ -1223,8 +1244,19 @@ const selectTotalProgress = createSelector(unrealFeature.selectStatusPercentSign
1223
1244
  const progressRest = lerp(0, 1 - splitPoint, commandProgress);
1224
1245
  return clampf(0, 1, progressSignaling + progressRest);
1225
1246
  });
1226
- /** Used by external consumers (e.g., metabox). Do not remove. */
1227
- const selectCirrusConnectionId = createSelector(unrealFeature.selectSsData, (data) => data?.connectionId);
1247
+ /**
1248
+ * The connection id of the current stream session.
1249
+ *
1250
+ * Primary source is the orchestration, which issues it as `streamRequestId` —
1251
+ * in the polling response (legacy flow) or in `instanceReserved`/`instanceReady`
1252
+ * (new orchestration) — and the browser hands it to Cirrus on the WS upgrade
1253
+ * URL. `ssData.connectionId` (the `ssInfo` message) is the fallback for
1254
+ * backends that don't issue one yet: Cirrus then generates it itself and
1255
+ * reports it back here.
1256
+ *
1257
+ * Used by external consumers (e.g., metabox). Do not remove.
1258
+ */
1259
+ const selectCirrusConnectionId = createSelector(unrealFeature.selectAwsInstance, unrealFeature.selectSsData, (awsInstance, ssData) => awsInstance?.streamRequestId ?? ssData?.connectionId);
1228
1260
  /** Used by external consumers (e.g., metabox). Do not remove. */
1229
1261
  const selectUserInactivityDetected = createSelector(unrealFeature.selectAfkCountdown, unrealFeature.selectIsAfkTimerVisible, (remainingSecondsToDisconnect, countdownVisible) => ({
1230
1262
  remainingSecondsToDisconnect,
@@ -1818,10 +1850,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.0", ngImpor
1818
1850
  }], ctorParameters: () => [] });
1819
1851
 
1820
1852
  function StreamerListHandler(msg) {
1821
- // `subscribe` is player signalling — reply on the active signalling socket
1822
- // (stream socket in the new flow, relay socket in the old polling flow),
1823
- // matching the socket the `streamerList` arrived on.
1824
- this.sendPlayerSignal(JSON.stringify({
1853
+ // `subscribe` is player signalling → the Cirrus socket.
1854
+ this.send(JSON.stringify({
1825
1855
  type: 'subscribe',
1826
1856
  streamerId: msg.ids[0],
1827
1857
  }));
@@ -1835,6 +1865,27 @@ function httpUrlToWs(url) {
1835
1865
  return url.replace('http://', 'ws://').replace('https://', 'wss://');
1836
1866
  }
1837
1867
 
1868
+ /**
1869
+ * Append the orchestration-issued connection id to a signalling WS URL as
1870
+ * `?streamRequestId=…`.
1871
+ *
1872
+ * Cirrus reads it in its `onConnection` upgrade handler and uses it as the
1873
+ * player's `connectionId` instead of generating one (both the legacy Cirrus and
1874
+ * the new Cirrus-WS keep their `guid()` generator as a fallback). A missing id
1875
+ * is a no-op, so the URL stays byte-identical for backends that don't issue one.
1876
+ *
1877
+ * Only used at the moment of connecting — the URL kept in the store stays clean
1878
+ * (it is rendered in the debug overlay and pattern-matched by
1879
+ * `selectTotalProgress`).
1880
+ */
1881
+ function withStreamRequestId(wsUrl, streamRequestId) {
1882
+ if (!wsUrl || !streamRequestId) {
1883
+ return wsUrl;
1884
+ }
1885
+ const separator = wsUrl.includes('?') ? '&' : '?';
1886
+ return `${wsUrl}${separator}streamRequestId=${encodeURIComponent(streamRequestId)}`;
1887
+ }
1888
+
1838
1889
  /**
1839
1890
  * Factory that creates a WebSocket connection and wraps it in an RxJS Observable.
1840
1891
  *
@@ -2007,19 +2058,6 @@ function createWebSocket(wsUrl, timeoutMs = WS_TIMEOUT) {
2007
2058
  }));
2008
2059
  }
2009
2060
 
2010
- function PlayerDisconnectHandler() {
2011
- this.store.dispatch(disconnectStream({
2012
- reason: DisconnectReason.OrchestrationPlayerDisconnected,
2013
- message: 'Orchestration: PlayerDisconnectHandler',
2014
- }));
2015
- }
2016
- function StreamerDisconnectHandler() {
2017
- this.store.dispatch(disconnectStream({
2018
- reason: DisconnectReason.OrchestrationStreamerDisconnected,
2019
- message: 'Orchestration: StreamerDisconnectHandler',
2020
- }));
2021
- }
2022
-
2023
2061
  // Structured constants holding code + description
2024
2062
  const ORCHESTRATION_ERROR_CODES = {
2025
2063
  NO_AVAILABLE_INSTANCES: {
@@ -2044,6 +2082,16 @@ function OrchestrationErrorHandler(msg) {
2044
2082
  if (RETRY_CODES.has(msg.payload.code)) {
2045
2083
  const errorInfo = Object.values(ORCHESTRATION_ERROR_CODES).find(({ code }) => code === msg.payload.code);
2046
2084
  this.store.dispatch(setOrchestrationMessage({ message: errorInfo?.description }));
2085
+ // Same telemetry channel as the WS/URL/region retry tiers — this branch used
2086
+ // to resend silently, so `kind: 'orchestration'` was declared but never seen.
2087
+ // No count cap here on purpose: the wait is bounded by
2088
+ // `INSTANCE_READY_TIMEOUT` in `requestCirrusViaSocket`, and on expiry the
2089
+ // URL-rotating tier takes over.
2090
+ this.store.dispatch(signalingRetryAttempted({
2091
+ attempt: ++this.orchestrationRetryCount,
2092
+ reason: errorInfo?.description ?? `code ${msg.payload.code}`,
2093
+ kind: 'orchestration',
2094
+ }));
2047
2095
  this.orchestrationRetrySub?.unsubscribe();
2048
2096
  this.orchestrationRetrySub = timer(POLLING_TIME)
2049
2097
  .pipe(takeUntil(this.destroy$))
@@ -2067,21 +2115,27 @@ class SignallingService extends SubService {
2067
2115
  this.onConfig$ = new Subject();
2068
2116
  this.onWebRtcIce$ = new Subject();
2069
2117
  this.onWebRtcAnswer$ = new Subject();
2118
+ // Fed by `InstanceReadyHandler`. `requestCirrusViaSocket` awaits it the same
2119
+ // way `requestCirrusViaPolling` awaits a ready polling response — it is the
2120
+ // socket flow's "here is your Cirrus URL" answer.
2121
+ this.onInstanceReady$ = new Subject();
2070
2122
  // Pure signal — no side-effects. The `close()` is wired ONCE in init()
2071
2123
  // below so a single abort event triggers exactly one close, regardless
2072
2124
  // of how many pipelines have `takeUntil(this.abort$)` attached.
2073
2125
  this.abort$ = this.actions$.pipe(ofType(abortEstablishingConnection));
2126
+ // The CIRRUS signalling socket — the only socket that speaks WebRTC
2127
+ // (config/offer/answer/iceCandidate/ping). Same field in all three flows:
2128
+ // the polled `signallingServer`, the stream machine (`originInstanceUri`), or
2129
+ // a direct dev URL.
2074
2130
  this.ws = null;
2075
- // Second socket, opened directly to the stream machine (originInstanceUri
2076
- // from `instanceReady`). Carries the whole WebRTC signalling
2077
- // (config/offer/answer/iceCandidate/ping); `this.ws` stays for control.
2078
- this.streamWs = null;
2079
- // True once the stream-machine socket is established. After this the
2080
- // orchestration server intentionally closes the browser's orchestration
2081
- // socket (instance is "occupied") — that close must NOT be treated as a drop.
2082
- this.streamMachineConnected = false;
2083
- // Bounded retry counter for the stream-machine connection (new-flow only).
2084
- this.streamMachineRetryCount = 0;
2131
+ // The orchestration CONTROL socket (socket flow only). Opened by
2132
+ // `requestCirrusViaSocket` to ask for a machine and closed the moment
2133
+ // `instanceReady` arrives — per the backend contract nothing uses it after the
2134
+ // handover, so it is never left open with dangling handlers.
2135
+ this.controlWs = null;
2136
+ // `requestStream` resends driven by `OrchestrationErrorHandler` within one
2137
+ // `requestCirrusViaSocket` wait. Reported as `kind: 'orchestration'` retries.
2138
+ this.orchestrationRetryCount = 0;
2085
2139
  this.wsMsgHandlers = {};
2086
2140
  this.init();
2087
2141
  }
@@ -2089,7 +2143,7 @@ class SignallingService extends SubService {
2089
2143
  if (this.unrealConfig?.playwright) {
2090
2144
  return;
2091
2145
  }
2092
- this.setHandlersFromStream();
2146
+ this.setMessageHandlers();
2093
2147
  // Single owner of the "close on abort" side-effect. Previously this
2094
2148
  // lived as a `tap` inside `abort$` itself, which made every
2095
2149
  // `takeUntil(this.abort$)` subscriber fire its own close().
@@ -2104,13 +2158,12 @@ class SignallingService extends SubService {
2104
2158
  // and `init()` runs once in the constructor. If these subs were torn
2105
2159
  // down by abort$, a second in-injector session (e.g. navigating between
2106
2160
  // two routes that both host <app-unreal-scene>) would never send
2107
- // `p2pEstablished`. `sendPlayerSignal()` is already a no-op on a closed WS,
2161
+ // `p2pEstablished`. `send()` is already a no-op on a closed WS,
2108
2162
  // so there's no risk of stray messages.
2109
2163
  this.actions$
2110
2164
  .pipe(ofType(dataChannelReady), takeUntil(this.destroy$))
2111
2165
  .subscribe(() => {
2112
- // Player signalling → active signalling socket (stream/relay).
2113
- this.sendPlayerSignal({
2166
+ this.send({
2114
2167
  type: 'p2pEstablished',
2115
2168
  source: 'front',
2116
2169
  sessionId: generateUuid(),
@@ -2161,7 +2214,7 @@ class SignallingService extends SubService {
2161
2214
  }
2162
2215
  /**
2163
2216
  * One inner cycle: rotate `urlGen` over the pool, resolve via
2164
- * {@link getAwsInstance}, open WS via {@link connectToCirrus},
2217
+ * {@link requestCirrus}, open WS via {@link connectToCirrus},
2165
2218
  * retry up to {@link INNER_RETRY_LIMIT} on any hard failure. The
2166
2219
  * urlGen lives in this closure so retries advance through the
2167
2220
  * pool. Exhaustion bubbles to {@link connectToSignaling}'s outer
@@ -2169,12 +2222,20 @@ class SignallingService extends SubService {
2169
2222
  */
2170
2223
  connectAttempt(urls) {
2171
2224
  const urlGen = getActiveUrl(urls);
2172
- return defer(() => this.getAwsInstance(`${urlGen.next().value}`)).pipe(tap((awsInstance) => this.store.dispatch(setAwsInstance(awsInstance))), switchMap(({ wsUrl }) =>
2225
+ return defer(() => {
2226
+ // One control socket per ATTEMPT, whatever transport this attempt picks —
2227
+ // a rotation from a `wss://` orchestration URL to an `http://` one would
2228
+ // otherwise leave the previous control socket behind. Deliberately NOT
2229
+ // inside the WS-only tier below: that tier reuses the same resolved
2230
+ // machine, so its control socket must survive.
2231
+ this.teardownControlWs();
2232
+ return this.requestCirrus(`${urlGen.next().value}`);
2233
+ }).pipe(tap((awsInstance) => this.store.dispatch(setAwsInstance(awsInstance))), switchMap(({ wsUrl, streamRequestId }) =>
2173
2234
  // ── WS-only retry tier ─────────────────────────────────────────────
2174
2235
  // Tries the SAME `wsUrl` up to `WS_RETRY_PER_INSTANCE` extra times
2175
2236
  // before letting the error bubble up to the outer (URL-rotating)
2176
2237
  // retry. This is important because the outer retry calls
2177
- // `getAwsInstance(url)` again, which polls orchestration again,
2238
+ // `requestCirrus(url)` again, which asks orchestration again,
2178
2239
  // which causes orchestrator to RESERVE A NEW instance — the
2179
2240
  // previous reservation is orphaned (server-side leak).
2180
2241
  //
@@ -2183,7 +2244,11 @@ class SignallingService extends SubService {
2183
2244
  // burning a second reservation. If WS still fails after these
2184
2245
  // extra attempts, the instance is genuinely unusable and we
2185
2246
  // accept the cost of rotating.
2186
- this.connectToCirrus(wsUrl ?? '').pipe(filter(Truthy), take(1), retry({
2247
+ // `wsUrl` is always a CIRRUS address here, whichever request method
2248
+ // produced it, so the orchestration-issued connection id always rides
2249
+ // on this upgrade URL and Cirrus adopts it instead of generating its
2250
+ // own. Absent id (older backend, direct dev URL) → URL unchanged.
2251
+ this.connectToCirrus(withStreamRequestId(wsUrl ?? '', streamRequestId)).pipe(filter(Truthy), take(1), retry({
2187
2252
  count: WS_RETRY_PER_INSTANCE,
2188
2253
  delay: (err, attempt) => {
2189
2254
  this.store.dispatch(signalingRetryAttempted({
@@ -2236,22 +2301,52 @@ class SignallingService extends SubService {
2236
2301
  });
2237
2302
  }
2238
2303
  /**
2239
- * Resolve one URL to an `AwsInstance`. Dispatches to
2240
- * {@link directWsInstance} for `ws://` / `wss://` URLs, otherwise
2241
- * to {@link pollOrchestration}.
2304
+ * Ask the orchestration for a Cirrus to connect to. All three
2305
+ * implementations answer the same question — "which Cirrus, and with which
2306
+ * `streamRequestId`?" — and emit ONE `AwsInstance` whose `wsUrl` is a Cirrus
2307
+ * address. What differs is only how the answer is obtained and therefore how
2308
+ * it can fail:
2309
+ *
2310
+ * | Method | Transport | Fails on |
2311
+ * | -------------------------- | -------------------- | ------------------------------ |
2312
+ * | {@link requestCirrusViaPolling} | HTTP poll loop | HTTP 4xx (dead URL) |
2313
+ * | {@link requestCirrusViaSocket} | control WS | socket drop / no `instanceReady` in {@link INSTANCE_READY_TIMEOUT} |
2314
+ * | {@link requestCirrusDirect} | none (dev) | never — nothing is requested |
2315
+ *
2316
+ * Whatever they return goes through the SAME downstream in
2317
+ * {@link connectAttempt}: `setAwsInstance` → `connectToCirrus` → the WS-only
2318
+ * and URL-rotating retry tiers. A failure inside any of them is just an
2319
+ * error on this observable, so those tiers handle it uniformly.
2320
+ */
2321
+ requestCirrus(url) {
2322
+ if (!url.match(/^ws(s?):\/\//i)) {
2323
+ return this.requestCirrusViaPolling(url);
2324
+ }
2325
+ // A `ws://` URL is the orchestration control endpoint when the app supplied
2326
+ // a stream-request context (the same precondition `sendRequestStream` needs);
2327
+ // without it there is nothing to negotiate and the URL is Cirrus itself.
2328
+ return this.hasOrchestrationContext()
2329
+ ? this.requestCirrusViaSocket(url)
2330
+ : this.requestCirrusDirect(url);
2331
+ }
2332
+ /** True when `requestStream` can be composed → new orchestration. */
2333
+ hasOrchestrationContext() {
2334
+ return !!this.streamRequestContext() && !!this.environmentId();
2335
+ }
2336
+ /**
2337
+ * Wait until `clientId` and `viewId` are known — every request method needs
2338
+ * them before it may talk to the backend.
2242
2339
  */
2243
- getAwsInstance(url) {
2244
- return url.match(/^ws(s?):\/\//i)
2245
- ? this.directWsInstance(url)
2246
- : this.pollOrchestration(url);
2340
+ waitForClientAndViewIds() {
2341
+ return this.store.select(selectClientAndViewIds).pipe(filter(({ clientId, viewId }) => !!clientId && !!viewId), take(1));
2247
2342
  }
2248
2343
  /**
2249
- * Direct `ws://` / `wss://` short-circuit — no HTTP orchestration.
2250
- * Waits for `clientId` and `viewId` to be truthy in the store, then
2251
- * emits a `Direct Connection` `AwsInstance` and completes.
2344
+ * Direct `ws://` / `wss://` short-circuit — no orchestration at all, the URL
2345
+ * already points at Cirrus. Dev/playwright convenience, hence no
2346
+ * `kind`: nothing was requested.
2252
2347
  */
2253
- directWsInstance(url) {
2254
- return this.store.select(selectClientAndViewIds).pipe(filter(({ clientId, viewId }) => !!clientId && !!viewId), take(1), map$1(() => ({
2348
+ requestCirrusDirect(url) {
2349
+ return this.waitForClientAndViewIds().pipe(map$1(() => ({
2255
2350
  wsUrl: url,
2256
2351
  pollingUrl: null,
2257
2352
  instanceName: 'Direct Connection',
@@ -2264,7 +2359,7 @@ class SignallingService extends SubService {
2264
2359
  * last response captured in the closure). Maps the final ready
2265
2360
  * response to an `AwsInstance`.
2266
2361
  */
2267
- pollOrchestration(url) {
2362
+ requestCirrusViaPolling(url) {
2268
2363
  return defer(() => {
2269
2364
  let lastResp;
2270
2365
  this.store.dispatch(pollingStarted());
@@ -2279,12 +2374,87 @@ class SignallingService extends SubService {
2279
2374
  wsUrl: httpUrlToWs(`${location.protocol}//${data.signallingServer}`),
2280
2375
  pollingUrl: url,
2281
2376
  instanceName: data.signallingServer ?? '',
2377
+ // The orchestration issues the connection id here; it is handed to
2378
+ // Cirrus on the WS upgrade URL (see `connectAttempt`).
2379
+ streamRequestId: data.streamRequestId,
2380
+ kind: 'polling',
2282
2381
  })), finalize(() => {
2283
2382
  this.commandTelemetryService.trackStopCommand('EXT-getSignaling', true, { ...(lastResp ?? {}), multi: true });
2284
2383
  this.store.dispatch(pollingEnded());
2285
2384
  }));
2286
2385
  });
2287
2386
  }
2387
+ /**
2388
+ * WS-orchestration path — the socket-transport twin of
2389
+ * {@link requestCirrusViaPolling}. Opens the control socket, sends
2390
+ * `requestStream` and waits for `instanceReady`, whose `originInstanceUri`
2391
+ * IS the Cirrus address.
2392
+ *
2393
+ * Failure modes are handled here so they surface as a plain error and reach
2394
+ * `connectAttempt`'s retry tiers (previously this branch lived outside them
2395
+ * and a dead control socket silently hung the session):
2396
+ * - control socket closes/errors before `instanceReady` → error
2397
+ * - no `instanceReady` within {@link INSTANCE_READY_TIMEOUT} → error
2398
+ *
2399
+ * The control socket lives exactly as long as this wait — see
2400
+ * {@link awaitInstanceReady}.
2401
+ */
2402
+ requestCirrusViaSocket(url) {
2403
+ return defer(() => {
2404
+ let resolved;
2405
+ this.orchestrationRetryCount = 0;
2406
+ this.commandTelemetryService.trackStartCommand('EXT-getSignaling');
2407
+ return this.waitForClientAndViewIds().pipe(switchMap(() => createWebSocket(url)), switchMap((control) => this.awaitInstanceReady(control, url)), tap((instance) => {
2408
+ resolved = instance;
2409
+ }), finalize(() => this.commandTelemetryService.trackStopCommand('EXT-getSignaling', true, { ...(resolved ?? {}), multi: true })));
2410
+ });
2411
+ }
2412
+ /**
2413
+ * Send `requestStream` on the freshly opened control socket and resolve with
2414
+ * the machine coordinates from `instanceReady`.
2415
+ *
2416
+ * The socket lives exactly as long as this wait: its close/error are HARD
2417
+ * failures of the request, and on resolution it is closed with every handler
2418
+ * detached (the backend does not use it after the handover). So the control
2419
+ * socket has no post-handover state at all — nothing that could report a stale
2420
+ * "disconnect" against a live stream.
2421
+ */
2422
+ awaitInstanceReady(control, orchestrationUrl) {
2423
+ return new Observable((observer) => {
2424
+ control.onmessage = OnMessageHandler.bind(this);
2425
+ control.onclose = (e) => observer.error(new Error(`Orchestration socket closed before instanceReady (code ${e.code})`));
2426
+ control.onerror = () => observer.error(new Error('Orchestration socket error before instanceReady'));
2427
+ this.controlWs = control;
2428
+ Logger.log('ORCHESTRATION CONTROL WS OPEN');
2429
+ this.sendRequestStream();
2430
+ const sub = this.onInstanceReady$.pipe(take(1)).subscribe((payload) => {
2431
+ // The control socket has done its only job. Per the backend contract it
2432
+ // is not used after the handover, so it is closed here — with all its
2433
+ // handlers detached by `teardownSocket`, which also means the close
2434
+ // itself cannot come back as an error above.
2435
+ this.teardownControlWs();
2436
+ observer.next({
2437
+ wsUrl: payload.originInstanceUri,
2438
+ // The endpoint that answered — the orchestration URL here, the HTTP
2439
+ // polling URL in the other flow. Shown as `Orchestration:` in the overlay.
2440
+ pollingUrl: orchestrationUrl,
2441
+ instanceName: payload.originInstanceId,
2442
+ streamRequestId: payload.streamRequestId,
2443
+ kind: 'socket',
2444
+ });
2445
+ observer.complete();
2446
+ });
2447
+ // Errored, timed out or aborted before `instanceReady` — same teardown,
2448
+ // the socket must not be left behind either way.
2449
+ return () => {
2450
+ sub.unsubscribe();
2451
+ this.teardownControlWs();
2452
+ };
2453
+ }).pipe(timeout$1({
2454
+ first: INSTANCE_READY_TIMEOUT,
2455
+ with: () => throwError(() => new Error(`No instanceReady within ${INSTANCE_READY_TIMEOUT}ms — requesting another machine`)),
2456
+ }), takeUntil(this.abort$));
2457
+ }
2288
2458
  /**
2289
2459
  * Poll the SAME `url` until ready, or until HTTP 4xx says the URL
2290
2460
  * is dead.
@@ -2341,37 +2511,31 @@ class SignallingService extends SubService {
2341
2511
  // still create a new socket, but in tests would re-emit the same
2342
2512
  // (already-errored) mock observable.
2343
2513
  return defer(() => {
2344
- this.close({ code: 1000, reason: DisconnectReason.NewConnectionAttempt });
2514
+ // Only the Cirrus socket — the control socket belongs to
2515
+ // `requestCirrusViaSocket`, which already opened it to GET this `wsUrl`.
2516
+ this.closeCirrus({
2517
+ code: 1000,
2518
+ reason: DisconnectReason.NewConnectionAttempt,
2519
+ });
2345
2520
  return createWebSocket(wsUrl);
2346
2521
  }).pipe(takeUntil(this.abort$), tapLog('WS CREATED'), tap((ws) => {
2347
2522
  this.commandTelemetryService.trackStartCommand('EXT-iceCandidate');
2348
2523
  this.addWsHandlers(ws);
2349
2524
  }));
2350
2525
  }
2526
+ /**
2527
+ * Handlers for the CIRRUS socket — the only long-lived socket. Its close/error
2528
+ * are real stream events (`setCirrusDisconnected` / `dropConnection`), unlike
2529
+ * the control socket's, which never outlives {@link awaitInstanceReady}.
2530
+ */
2351
2531
  addWsHandlers(ws) {
2352
2532
  ws.onmessage = OnMessageHandler.bind(this);
2353
- // Orchestration socket close/error is benign ONCE the stream machine is
2354
- // connected (orchestration closes it after marking the instance occupied).
2355
- ws.onclose = (e) => this.onOrchestrationClose(e);
2356
- ws.onerror = (e) => this.onOrchestrationError(e);
2533
+ ws.onclose = OnCloseHandler.bind(this);
2534
+ ws.onerror = OnErrorHandler.bind(this);
2357
2535
  this.ws = ws;
2358
2536
  Logger.log('WS HANDLERS ADDED');
2359
2537
  OnOpenHandler.call(this);
2360
2538
  }
2361
- onOrchestrationClose(e) {
2362
- if (this.streamMachineConnected) {
2363
- Logger.log(`Orchestration socket closed after occupancy (benign): ${e.code}`);
2364
- return;
2365
- }
2366
- OnCloseHandler.call(this, e);
2367
- }
2368
- onOrchestrationError(e) {
2369
- if (this.streamMachineConnected) {
2370
- Logger.log('Orchestration socket error after occupancy (benign)');
2371
- return;
2372
- }
2373
- OnErrorHandler.call(this, e);
2374
- }
2375
2539
  /**
2376
2540
  * Adopt a live signalling socket opened by the `stream-prefetch.js`
2377
2541
  * head-script. The socket is already OPEN and `requestStream` was already
@@ -2388,97 +2552,32 @@ class SignallingService extends SubService {
2388
2552
  this.ws = ws;
2389
2553
  Logger.log('WS ADOPTED (handshake already done, requestStream already sent)');
2390
2554
  }
2391
- /**
2392
- * Open the SECOND socket, directly to the stream machine's Cirrus
2393
- * (`originInstanceUri` from `instanceReady`). The whole WebRTC signalling
2394
- * (config/offer/answer/iceCandidate/ping) flows over this socket; the
2395
- * orchestration socket (`this.ws`) stays open for control/lifecycle.
2396
- *
2397
- * "Just connect" — nothing is sent on open (the machine is already reserved
2398
- * for this session): Cirrus pushes `config` and Unreal sends the `offer`.
2399
- */
2400
- connectToStreamMachine(originInstanceUri) {
2401
- if (!originInstanceUri) {
2402
- Logger.warn('instanceReady without originInstanceUri — stream socket not opened');
2403
- return;
2404
- }
2405
- // Guard: a stream socket is already live. A duplicate / re-sent
2406
- // `instanceReady` must NOT tear down the active stream.
2407
- if (this.streamMachineConnected) {
2408
- Logger.warn('Stream machine already connected — ignoring duplicate instanceReady');
2409
- return;
2410
- }
2411
- // Drop any prior (half-open) stream socket AND cancel any in-flight connect
2412
- // before a fresh attempt — e.g. a retry that resolved to a different machine
2413
- // or two instanceReady messages before the first connect resolves.
2414
- this.teardownStreamWs();
2415
- this.streamWsSub = createWebSocket(originInstanceUri)
2416
- .pipe(takeUntil(this.abort$), takeUntil(this.destroy$), take(1))
2417
- .subscribe({
2418
- next: (ws) => {
2419
- this.streamMachineRetryCount = 0;
2420
- this.addStreamWsHandlers(ws);
2421
- },
2422
- error: (err) => {
2423
- Logger.error('Stream-machine WS failed to connect', err?.message);
2424
- this.retryStreamMachine();
2425
- },
2426
- });
2427
- }
2428
- /**
2429
- * The stream-machine (Cirrus) connection failed. Re-request a machine over
2430
- * the still-open orchestration socket: orchestration issues a new
2431
- * streamRequestId and a fresh `instanceReady(originInstanceUri)`, which calls
2432
- * {@link connectToStreamMachine} again on a different machine. Bounded by
2433
- * {@link INNER_RETRY_LIMIT}; on exhaustion the stream is dropped.
2434
- */
2435
- retryStreamMachine() {
2436
- if (this.streamMachineRetryCount >= INNER_RETRY_LIMIT) {
2437
- Logger.error('Stream-machine connection exhausted retries — dropping');
2438
- this.store.dispatch(disconnectStream({
2439
- reason: DisconnectReason.ReconnectFailed,
2440
- message: 'Could not connect to the stream machine',
2441
- }));
2442
- return;
2443
- }
2444
- this.streamMachineRetryCount++;
2445
- this.orchestrationRetrySub?.unsubscribe();
2446
- this.orchestrationRetrySub = timer(POLLING_TIME)
2447
- .pipe(takeUntil(this.abort$))
2448
- .subscribe(() => this.sendRequestStream());
2449
- }
2450
- addStreamWsHandlers(ws) {
2451
- ws.onmessage = OnMessageHandler.bind(this);
2452
- ws.onclose = OnCloseHandler.bind(this);
2453
- ws.onerror = OnErrorHandler.bind(this);
2454
- this.streamWs = ws;
2455
- this.streamMachineConnected = true;
2456
- Logger.log('STREAM-MACHINE WS CONNECTED');
2457
- }
2458
- teardownStreamWs() {
2459
- this.streamWsSub?.unsubscribe();
2460
- this.teardownSocket(this.streamWs, 1000, DisconnectReason.NewConnectionAttempt);
2461
- this.streamWs = null;
2462
- this.streamMachineConnected = false;
2463
- }
2464
2555
  showStatusMessage(statusMessage) {
2465
2556
  this.store.dispatch(setStatusMessage({ statusMessage }));
2466
2557
  }
2467
2558
  /**
2468
- * Close the connection with the signaling server
2469
- * @param data
2559
+ * Close everything: the Cirrus socket AND the orchestration control socket.
2560
+ * Used on abort/teardown. A new connection attempt inside a live cycle uses
2561
+ * {@link closeCirrus} instead, so it does not kill the control socket that
2562
+ * produced the address it is about to connect to.
2470
2563
  */
2471
2564
  close(data) {
2472
2565
  const { code, reason } = data;
2473
- this.onWebRtcAnswer$.next(null);
2474
2566
  this.orchestrationRetrySub?.unsubscribe();
2475
- this.streamWsSub?.unsubscribe();
2567
+ this.closeCirrus(data);
2568
+ this.teardownControlWs(code, reason);
2569
+ }
2570
+ /** Close only the Cirrus signalling socket. */
2571
+ closeCirrus(data) {
2572
+ const { code, reason } = data;
2573
+ this.onWebRtcAnswer$.next(null);
2476
2574
  this.teardownSocket(this.ws, code, reason);
2477
2575
  this.ws = null;
2478
- this.teardownSocket(this.streamWs, code, reason);
2479
- this.streamWs = null;
2480
- this.streamMachineConnected = false;
2481
- this.streamMachineRetryCount = 0;
2576
+ }
2577
+ teardownControlWs(code = 1000, reason = DisconnectReason.NewConnectionAttempt) {
2578
+ this.teardownSocket(this.controlWs, code, reason);
2579
+ this.controlWs = null;
2580
+ this.orchestrationRetryCount = 0;
2482
2581
  }
2483
2582
  teardownSocket(ws, code, reason) {
2484
2583
  if (!ws) {
@@ -2495,17 +2594,16 @@ class SignallingService extends SubService {
2495
2594
  ws.close(code, reason);
2496
2595
  }
2497
2596
  }
2597
+ /**
2598
+ * Player signalling (offer/answer/iceCandidate/pong/p2pEstablished/subscribe)
2599
+ * — always the Cirrus socket, in every flow.
2600
+ */
2498
2601
  send(data) {
2499
2602
  this.sendTo(this.ws, data);
2500
2603
  }
2501
- /**
2502
- * Send player signalling (offer/answer/iceCandidate/pong/p2pEstablished/
2503
- * subscribe) over the ACTIVE signalling socket: the stream-machine socket in
2504
- * the new flow (once connected), or the orchestration/relay socket in the old
2505
- * polling flow (where there is no separate stream socket).
2506
- */
2507
- sendPlayerSignal(data) {
2508
- this.sendTo(this.streamMachineConnected ? this.streamWs : this.ws, data);
2604
+ /** Orchestration control messages (`requestStream`) — the control socket. */
2605
+ sendToOrchestration(data) {
2606
+ this.sendTo(this.controlWs, data);
2509
2607
  }
2510
2608
  sendTo(ws, data) {
2511
2609
  let dataString = data;
@@ -2535,21 +2633,45 @@ class SignallingService extends SubService {
2535
2633
  Logger.colored(...COLOR_CODES.FROM_CIRRUS_ERROR, `Invalid CIRRUS message type: ${msg.type}`);
2536
2634
  }
2537
2635
  }
2538
- setHandlersFromStream() {
2539
- this.wsMsgHandlers.ping = PingHandler.bind(this);
2636
+ /**
2637
+ * One handler map shared by both sockets — `handleMessage` dispatches purely by
2638
+ * message type. Which socket a type can arrive on is fixed, so registration is
2639
+ * split by origin: {@link setOrchestrationHandlers} (control socket) and
2640
+ * {@link setStreamHandlers} (Cirrus).
2641
+ *
2642
+ * Keepalive is deliberately absent: both servers ping at the WS **protocol**
2643
+ * level (`ws.ping()`), which the browser answers on its own — the page never
2644
+ * sees a ping and must never reply with an application-level `pong`. An
2645
+ * unregistered type is dropped silently by `handleMessage`, so a `ping` from a
2646
+ * not-yet-updated backend is simply ignored.
2647
+ */
2648
+ setMessageHandlers() {
2649
+ this.setOrchestrationHandlers();
2650
+ this.setStreamHandlers();
2651
+ }
2652
+ /**
2653
+ * Messages from the ORCHESTRATION control socket. Only reachable during the
2654
+ * provisioning wait — `requestCirrusViaSocket` closes that socket the moment
2655
+ * `instanceReady` lands.
2656
+ */
2657
+ setOrchestrationHandlers() {
2658
+ // `instanceReserved` (eta/progress) and `instanceReady` (the Cirrus address)
2659
+ // ARE the request/response pair of `requestCirrusViaSocket`.
2660
+ this.wsMsgHandlers.instanceReserved = InstanceReservedHandler.bind(this);
2661
+ this.wsMsgHandlers.instanceReady = InstanceReadyHandler.bind(this);
2662
+ // Orchestration-side refusals (4001 no instances / 4002 limit reached) →
2663
+ // resend `requestStream`, bounded by `INSTANCE_READY_TIMEOUT`.
2540
2664
  this.wsMsgHandlers.error = OrchestrationErrorHandler.bind(this);
2665
+ }
2666
+ /** Messages from the CIRRUS socket — the actual stream signalling. */
2667
+ setStreamHandlers() {
2541
2668
  this.wsMsgHandlers.config = ConfigHandler.bind(this);
2669
+ this.wsMsgHandlers.offer = OfferHandler.bind(this);
2542
2670
  this.wsMsgHandlers.answer = AnswerHandler.bind(this);
2671
+ this.wsMsgHandlers.iceCandidate = IceCandidateHandler.bind(this);
2543
2672
  this.wsMsgHandlers.ssInfo = SSInfoHandler.bind(this);
2544
2673
  this.wsMsgHandlers.playerCount = PlayerCountHandler.bind(this);
2545
2674
  this.wsMsgHandlers.streamerList = StreamerListHandler.bind(this);
2546
- this.wsMsgHandlers.iceCandidate = IceCandidateHandler.bind(this);
2547
- this.wsMsgHandlers.instanceReady = InstanceReadyHandler.bind(this);
2548
- this.wsMsgHandlers.instanceReserved = InstanceReservedHandler.bind(this);
2549
- this.wsMsgHandlers.playerDisconnected = PlayerDisconnectHandler.bind(this);
2550
- this.wsMsgHandlers.streamerDisconnected =
2551
- StreamerDisconnectHandler.bind(this);
2552
- this.wsMsgHandlers.offer = OfferHandler.bind(this);
2553
2675
  }
2554
2676
  sendRequestStream() {
2555
2677
  const context = this.streamRequestContext();
@@ -2557,7 +2679,7 @@ class SignallingService extends SubService {
2557
2679
  if (!context || !environmentId) {
2558
2680
  return;
2559
2681
  }
2560
- this.send({
2682
+ this.sendToOrchestration({
2561
2683
  type: OrchestrationMessageTypes.requestStream,
2562
2684
  correlationId: this.correlationId,
2563
2685
  payload: {
@@ -2568,18 +2690,12 @@ class SignallingService extends SubService {
2568
2690
  });
2569
2691
  }
2570
2692
  /**
2571
- * Check if the ACTIVE player-signalling socket is open and ready. Mirrors
2572
- * {@link sendPlayerSignal}'s socket choice so peer-reconnect gating is correct
2573
- * in both flows:
2574
- * - NEW flow (`streamMachineConnected`): the stream-machine socket
2575
- * (`streamWs`). The orchestration socket (`this.ws`) is intentionally
2576
- * closed after occupancy, so checking it would wrongly report "not
2577
- * connected" and block reconnect.
2578
- * - OLD polling flow: the single relay socket (`this.ws`) — unchanged.
2693
+ * Is the player-signalling socket open? Always the Cirrus socket — the
2694
+ * control socket's state is irrelevant to the stream (orchestration closes it
2695
+ * once the instance is occupied), so it must not be part of this gate.
2579
2696
  */
2580
2697
  isConnected() {
2581
- const activeWs = this.streamMachineConnected ? this.streamWs : this.ws;
2582
- return !!(activeWs && activeWs.readyState === WS_OPEN_STATE);
2698
+ return !!(this.ws && this.ws.readyState === WS_OPEN_STATE);
2583
2699
  }
2584
2700
  getRegion() {
2585
2701
  Logger.log('Start Getting Region');
@@ -3498,7 +3614,7 @@ class WebRtcPlayerService extends SubService {
3498
3614
  });
3499
3615
  fromEvent(pcClient, 'icecandidate')
3500
3616
  .pipe(map$1((e) => e.candidate), filter(Truthy), tap$1((candidate) => Logger.info('[Browser ICE candidate]', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|')), takeUntil(merge(this.disconnect$, this.destroy$)))
3501
- .subscribe((candidate) => this.signallingSrv.sendPlayerSignal({
3617
+ .subscribe((candidate) => this.signallingSrv.send({
3502
3618
  type: 'iceCandidate',
3503
3619
  candidate,
3504
3620
  }));
@@ -3648,7 +3764,7 @@ class WebRtcPlayerService extends SubService {
3648
3764
  const offer = await pcClient.createOffer();
3649
3765
  offer.sdp = this.mungeSDP(offer.sdp ?? '');
3650
3766
  await pcClient.setLocalDescription(offer);
3651
- this.signallingSrv.sendPlayerSignal(offer);
3767
+ this.signallingSrv.send(offer);
3652
3768
  }
3653
3769
  catch {
3654
3770
  Logger.error("Couldn't create offer");
@@ -3663,7 +3779,7 @@ class WebRtcPlayerService extends SubService {
3663
3779
  const answer = await pcClient.createAnswer();
3664
3780
  answer.sdp = this.mungeSDP(answer.sdp ?? '');
3665
3781
  await pcClient.setLocalDescription(answer);
3666
- this.signallingSrv.sendPlayerSignal(answer);
3782
+ this.signallingSrv.send(answer);
3667
3783
  // Observability signal for the signaling waterfall — the handshake
3668
3784
  // went "answer sent" between `offer received` and `datachannel_ready`.
3669
3785
  this.store.dispatch(answerSent());
@@ -3788,6 +3904,7 @@ class StreamAdoptionService {
3788
3904
  this.store.dispatch(streamAdopted({
3789
3905
  machineId: parked.machineId ?? null,
3790
3906
  ssData: parked.ssData ?? null,
3907
+ streamRequestId: parked.ids?.streamRequestId || null,
3791
3908
  timings: parked.timings ?? null,
3792
3909
  }));
3793
3910
  // Hand the live socket over without re-sending `requestStream`, reusing the
@@ -4089,9 +4206,12 @@ class UnrealEffects {
4089
4206
  return this.actions$.pipe(ofType(setAwsInstance),
4090
4207
  // require wsUrl present on the action payload
4091
4208
  filter((action) => !!action.wsUrl),
4092
- // for each awsInstance action capture one ssData snapshot, then wait for dataChannelConnected
4209
+ // for each awsInstance action capture one connectionId snapshot, then wait
4210
+ // for dataChannelConnected. The gate stays on `ssInfo` (unchanged timing);
4211
+ // the id itself now comes from the orchestration when it issued one, and
4212
+ // falls back to the `ssInfo` value otherwise.
4093
4213
  switchMap((awsInstance) => {
4094
- return this.actions$.pipe(ofType(updateCirrusInfo), take(1), switchMap(({ ssData }) => {
4214
+ return this.actions$.pipe(ofType(updateCirrusInfo), take(1), withLatestFrom(this.store.select(selectCirrusConnectionId)), switchMap(([, connectionId]) => {
4095
4215
  const abort$ = this.actions$.pipe(ofType(setCirrusDisconnected, // ← "Cirrus closed" action — direct,
4096
4216
  // no fan-in tick delay
4097
4217
  abortEstablishingConnection), take(1));
@@ -4108,12 +4228,20 @@ class UnrealEffects {
4108
4228
  Logger.error(error);
4109
4229
  const body = {
4110
4230
  awsInstance,
4111
- connectionId: ssData?.connectionId,
4231
+ connectionId,
4112
4232
  href: location.href,
4113
4233
  userAgent: navigator.userAgent,
4114
4234
  error,
4115
4235
  };
4116
- return this.http.post(awsInstance.pollingUrl || '', body).pipe(catchError((postError) => {
4236
+ // Only the polling flow has an HTTP endpoint to report to;
4237
+ // `pollingUrl` is a `wss://` orchestration address otherwise.
4238
+ const reportUrl = awsInstance.pollingUrl?.startsWith('http')
4239
+ ? awsInstance.pollingUrl
4240
+ : null;
4241
+ const report$ = reportUrl
4242
+ ? this.http.post(reportUrl, body)
4243
+ : of(null);
4244
+ return report$.pipe(catchError((postError) => {
4117
4245
  Logger.error('Error sending timeout info:', postError);
4118
4246
  return of(null);
4119
4247
  }),
@@ -8044,5 +8172,5 @@ function provideUnrealModule() {
8044
8172
  * Generated bundle index. Do not edit.
8045
8173
  */
8046
8174
 
8047
- export { AFKService, AUTO_HIGH_RESOLUTION_IDLE_MS, AfkTimeoutModalComponent, AggregatorService, AnalyticsService, AnswerHandler, CommandTelemetryService, ConfigHandler, ConsoleExtensionsService, ControlScheme, DATA_CHANNEL_CONNECTION_TIMEOUT, DEBOUNCE_TO_MANY_RESIZE_CALLS, DEFAULT_AFK_TIMEOUT, DEFAULT_AFK_TIMEOUT_PERIOD, DESTROY_DISCONNECT_REASONS, DevModeService, DisconnectReason, FULL_HD_HEIGHT, FULL_HD_WIDTH, FileHandlerService, FileReceiverService, FpsMonitorService, FreezeFrameComponent, FreezeFrameService, INNER_RETRY_LIMIT, IceCandidateHandler, ImageLoadingSrcComponent, InputOptions, InputService, InstanceReadyHandler, InstanceReservedHandler, IntroSrcComponent, LatencyTimings, LoadingProgressService, MINIMAL_FPS, MIN_CONTAINER_SIZE, MessageType, MouseButton, MouseButtonsMask, OUTER_RETRY_DELAY, OnCloseHandler, OnErrorHandler, OnMessageHandler, OnOpenHandler, OrchestrationMessageTypes, POLLING_TIME, PingHandler, PlayerCountHandler, RECONNECT_CONFIG, REGION_PING_ACCURATE_SAMPLES, REGION_PING_BACKGROUND_REFRESH_DELAY_MS, REGION_PING_CACHE_KEY, REGION_PING_CACHE_TTL_MS, REGION_PING_WARMUP_DEADLINE_MS, RegionsPingService, SAME_SIZE_THRESHOLD, SCREEN_LOCKER_CONTAINER_ID, SIGNALLING_PERCENT_VALUE, SSInfoHandler, STREAMING_AUDIO_ID, STREAMING_VIDEO_ID, SafePipe, SignallingService, SpecialKeyCodes, StatGraphComponent, StreamAdoptionService, 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, provideUnrealState, 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, streamAdopted, trackMixpanelEvent, trackTelemetryData, uint8ToBase64, unrealCommandCallback, unrealFeature, unrealReducer, updateCirrusInfo, videoAdaptedToContainer, videoStreamInitialized };
8175
+ export { AFKService, AUTO_HIGH_RESOLUTION_IDLE_MS, AfkTimeoutModalComponent, AggregatorService, AnalyticsService, AnswerHandler, CommandTelemetryService, ConfigHandler, ConsoleExtensionsService, ControlScheme, DATA_CHANNEL_CONNECTION_TIMEOUT, DEBOUNCE_TO_MANY_RESIZE_CALLS, DEFAULT_AFK_TIMEOUT, DEFAULT_AFK_TIMEOUT_PERIOD, DESTROY_DISCONNECT_REASONS, DevModeService, DisconnectReason, FULL_HD_HEIGHT, FULL_HD_WIDTH, FileHandlerService, FileReceiverService, FpsMonitorService, FreezeFrameComponent, FreezeFrameService, INNER_RETRY_LIMIT, INSTANCE_READY_TIMEOUT, IceCandidateHandler, ImageLoadingSrcComponent, InputOptions, InputService, InstanceReadyHandler, InstanceReservedHandler, IntroSrcComponent, LatencyTimings, LoadingProgressService, MINIMAL_FPS, MIN_CONTAINER_SIZE, MessageType, MouseButton, MouseButtonsMask, OUTER_RETRY_DELAY, OnCloseHandler, OnErrorHandler, OnMessageHandler, OnOpenHandler, OrchestrationMessageTypes, POLLING_TIME, PlayerCountHandler, RECONNECT_CONFIG, REGION_PING_ACCURATE_SAMPLES, REGION_PING_BACKGROUND_REFRESH_DELAY_MS, REGION_PING_CACHE_KEY, REGION_PING_CACHE_TTL_MS, REGION_PING_WARMUP_DEADLINE_MS, RegionsPingService, SAME_SIZE_THRESHOLD, SCREEN_LOCKER_CONTAINER_ID, SIGNALLING_PERCENT_VALUE, SSInfoHandler, STREAMING_AUDIO_ID, STREAMING_VIDEO_ID, SafePipe, SignallingService, SpecialKeyCodes, StatGraphComponent, StreamAdoptionService, 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, provideUnrealState, 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, streamAdopted, trackMixpanelEvent, trackTelemetryData, uint8ToBase64, unrealCommandCallback, unrealFeature, unrealReducer, updateCirrusInfo, videoAdaptedToContainer, videoStreamInitialized };
8048
8176
  //# sourceMappingURL=3dsource-angular-unreal-module.mjs.map