@3dsource/angular-unreal-module 0.0.161 → 0.0.163

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,6 +114,15 @@ The `loadChildren()` boundary keeps the streaming engine out of the initial
114
114
  bundle. Route-scoping `provideUnrealModule()` tears down its effects when the
115
115
  route is left.
116
116
 
117
+ > `UNREAL_CONFIG` on a route's `providers` does **not** work: the engine
118
+ > services are `providedIn: 'root'`, so they are created by the root injector
119
+ > and read the token from there. A route-level value is invisible to them and
120
+ > `inject(UNREAL_CONFIG, { optional: true })` resolves to `null` — e.g. region
121
+ > pinging is skipped entirely (empty `regionsPingUrl`), the orchestration
122
+ > `requestStream` goes out without a region and the post-connection re-ping
123
+ > never runs. Importing only the token at the root does not pull the module into
124
+ > the initial bundle (the package is `sideEffects: false`).
125
+
117
126
  ### 3. Render the scene
118
127
 
119
128
  ```typescript
@@ -174,6 +183,9 @@ The package publishes two dependency-free scripts for use in the document
174
183
  `stream-prefetch.js` runs by default only on
175
184
  `metabox-configurator/{modular|basic}/...` routes. It reads the same-origin
176
185
  `assets/config.json`; override that path with `data-config-url` when needed.
186
+ It also forwards the orchestration-issued `streamRequestId` from the polling
187
+ response to Cirrus on the WebSocket URL (the session `connectionId`) and parks it
188
+ for the Angular side to adopt.
177
189
  Pin an exact package version in production when deterministic CDN assets are
178
190
  required.
179
191
 
@@ -479,7 +479,7 @@ const initialState = {
479
479
  };
480
480
  const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScene, (state, { isVideoPlaying }) => {
481
481
  return { ...state, isVideoPlaying };
482
- }), on(setAwsInstance, (state, { instanceName, wsUrl, pollingUrl }) => {
482
+ }), on(setAwsInstance, (state, { instanceName, wsUrl, pollingUrl, streamRequestId }) => {
483
483
  return {
484
484
  ...state,
485
485
  awsInstance: {
@@ -487,6 +487,7 @@ const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScen
487
487
  instanceName,
488
488
  wsUrl,
489
489
  pollingUrl,
490
+ streamRequestId,
490
491
  progressComplete: 0,
491
492
  },
492
493
  };
@@ -642,7 +643,7 @@ const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScen
642
643
  return { ...state, analyticsEvent: event };
643
644
  }), on(setCirrusConnected, (state) => {
644
645
  return { ...state, cirrusConnected: true };
645
- }), on(streamAdopted, (state, { machineId, ssData }) => {
646
+ }), on(streamAdopted, (state, { machineId, ssData, streamRequestId }) => {
646
647
  return {
647
648
  ...state,
648
649
  cirrusConnected: true,
@@ -653,6 +654,7 @@ const unrealReducer = createReducer(initialState, on(changeStatusMainVideoOnScen
653
654
  wsUrl: machineId,
654
655
  pollingUrl: null,
655
656
  instanceName: 'Adopted Connection',
657
+ streamRequestId: streamRequestId ?? state.awsInstance.streamRequestId,
656
658
  progressComplete: 0,
657
659
  },
658
660
  };
@@ -782,8 +784,9 @@ function InstanceReadyHandler(msg) {
782
784
  this.store.dispatch(instanceReady(payload));
783
785
  // Open the SECOND socket directly to the stream machine and route the whole
784
786
  // WebRTC signalling through it. The orchestration socket stays open for
785
- // control/lifecycle.
786
- this.connectToStreamMachine(payload.originInstanceUri);
787
+ // control/lifecycle. `streamRequestId` is handed over so the stream machine's
788
+ // Cirrus reuses the orchestration-issued connection id.
789
+ this.connectToStreamMachine(payload.originInstanceUri, payload.streamRequestId);
787
790
  }
788
791
 
789
792
  function InstanceReservedHandler(msg) {
@@ -1223,8 +1226,19 @@ const selectTotalProgress = createSelector(unrealFeature.selectStatusPercentSign
1223
1226
  const progressRest = lerp(0, 1 - splitPoint, commandProgress);
1224
1227
  return clampf(0, 1, progressSignaling + progressRest);
1225
1228
  });
1226
- /** Used by external consumers (e.g., metabox). Do not remove. */
1227
- const selectCirrusConnectionId = createSelector(unrealFeature.selectSsData, (data) => data?.connectionId);
1229
+ /**
1230
+ * The connection id of the current stream session.
1231
+ *
1232
+ * Primary source is the orchestration, which issues it as `streamRequestId` —
1233
+ * in the polling response (legacy flow) or in `instanceReserved`/`instanceReady`
1234
+ * (new orchestration) — and the browser hands it to Cirrus on the WS upgrade
1235
+ * URL. `ssData.connectionId` (the `ssInfo` message) is the fallback for
1236
+ * backends that don't issue one yet: Cirrus then generates it itself and
1237
+ * reports it back here.
1238
+ *
1239
+ * Used by external consumers (e.g., metabox). Do not remove.
1240
+ */
1241
+ const selectCirrusConnectionId = createSelector(unrealFeature.selectAwsInstance, unrealFeature.selectSsData, (awsInstance, ssData) => awsInstance?.streamRequestId ?? ssData?.connectionId);
1228
1242
  /** Used by external consumers (e.g., metabox). Do not remove. */
1229
1243
  const selectUserInactivityDetected = createSelector(unrealFeature.selectAfkCountdown, unrealFeature.selectIsAfkTimerVisible, (remainingSecondsToDisconnect, countdownVisible) => ({
1230
1244
  remainingSecondsToDisconnect,
@@ -1538,6 +1552,16 @@ class RegionsPingService {
1538
1552
  pingsMs: data.result,
1539
1553
  }))), map$1((data) => data.region_code));
1540
1554
  }
1555
+ // No regions URL and no cache: there is nothing to ping. Bail out loudly
1556
+ // instead of letting `HttpClient.get('')` request the current document
1557
+ // (which yields HTML, fails to parse and silently resolves an empty
1558
+ // region). The usual cause is `UNREAL_CONFIG` provided on a lazy route
1559
+ // rather than at the application root — this service is
1560
+ // `providedIn: 'root'`, so a route-level value never reaches it.
1561
+ if (!regionListUrl) {
1562
+ Logger.warn('RegionsPingService: regionsPingUrl is empty — skipping region discovery. Provide UNREAL_CONFIG at the APPLICATION ROOT (a route-level provider is invisible to root-scoped services).');
1563
+ return of(undefined).pipe(tap(() => this.store.dispatch(regionPingFetchEnd())), tap(() => this.store.dispatch(regionResolved({ region: '', pingsMs: [] }))));
1564
+ }
1541
1565
  return this.getProviders(regionListUrl).pipe(tap(() => this.store.dispatch(regionPingFetchEnd())), switchMap((providers) => this.getPingResult(providers)), catchError(() => of(null)), tapLog('PingResult'),
1542
1566
  // Warm the cache with the live result so the next connection (and any
1543
1567
  // reload within the TTL) can skip pinging even if the prefetch script
@@ -1558,9 +1582,11 @@ class RegionsPingService {
1558
1582
  * All access is guarded — `localStorage` can throw (private mode, disabled
1559
1583
  * storage) or be absent (non-browser).
1560
1584
  *
1561
- * Public so adopted connections (which skip `getFastest`) can replay the
1585
+ * Public so adopted connections (which skip `getFastest`) can still report a
1562
1586
  * region-resolving telemetry phase from the same cached winner — see
1563
- * `UnrealEffects.regionTelemetryOnAdopt$`.
1587
+ * `StreamStatusTelemetryService2.emitAdoptedConnection`. Returns `null` when
1588
+ * the region-ping prefetch never ran or its cache expired; that phase then
1589
+ * carries no region and no ping samples.
1564
1590
  */
1565
1591
  readCachedWinner() {
1566
1592
  try {
@@ -1606,6 +1632,10 @@ class RegionsPingService {
1606
1632
  * connection handshake. Aborts with the injector via `destroyController`.
1607
1633
  */
1608
1634
  refreshCacheInBackground(regionListUrl = this.unrealConfig?.regionsPingUrl || '') {
1635
+ if (!regionListUrl) {
1636
+ Logger.warn('RegionsPingService: regionsPingUrl is empty — skipping the background region re-ping.');
1637
+ return of(null);
1638
+ }
1609
1639
  return defer(() => this.getProviders(regionListUrl)).pipe(switchMap((providers) => from(this.measureAccurate(providers.regions, providers.timeout ?? this.config.ping_timeout))), tap((winner) => {
1610
1640
  if (winner) {
1611
1641
  this.writeCachedWinner(winner.region_code, winner.result);
@@ -1819,6 +1849,27 @@ function httpUrlToWs(url) {
1819
1849
  return url.replace('http://', 'ws://').replace('https://', 'wss://');
1820
1850
  }
1821
1851
 
1852
+ /**
1853
+ * Append the orchestration-issued connection id to a signalling WS URL as
1854
+ * `?streamRequestId=…`.
1855
+ *
1856
+ * Cirrus reads it in its `onConnection` upgrade handler and uses it as the
1857
+ * player's `connectionId` instead of generating one (both the legacy Cirrus and
1858
+ * the new Cirrus-WS keep their `guid()` generator as a fallback). A missing id
1859
+ * is a no-op, so the URL stays byte-identical for backends that don't issue one.
1860
+ *
1861
+ * Only used at the moment of connecting — the URL kept in the store stays clean
1862
+ * (it is rendered in the debug overlay and pattern-matched by
1863
+ * `selectTotalProgress`).
1864
+ */
1865
+ function withStreamRequestId(wsUrl, streamRequestId) {
1866
+ if (!wsUrl || !streamRequestId) {
1867
+ return wsUrl;
1868
+ }
1869
+ const separator = wsUrl.includes('?') ? '&' : '?';
1870
+ return `${wsUrl}${separator}streamRequestId=${encodeURIComponent(streamRequestId)}`;
1871
+ }
1872
+
1822
1873
  /**
1823
1874
  * Factory that creates a WebSocket connection and wraps it in an RxJS Observable.
1824
1875
  *
@@ -2153,7 +2204,7 @@ class SignallingService extends SubService {
2153
2204
  */
2154
2205
  connectAttempt(urls) {
2155
2206
  const urlGen = getActiveUrl(urls);
2156
- return defer(() => this.getAwsInstance(`${urlGen.next().value}`)).pipe(tap((awsInstance) => this.store.dispatch(setAwsInstance(awsInstance))), switchMap(({ wsUrl }) =>
2207
+ return defer(() => this.getAwsInstance(`${urlGen.next().value}`)).pipe(tap((awsInstance) => this.store.dispatch(setAwsInstance(awsInstance))), switchMap(({ wsUrl, streamRequestId }) =>
2157
2208
  // ── WS-only retry tier ─────────────────────────────────────────────
2158
2209
  // Tries the SAME `wsUrl` up to `WS_RETRY_PER_INSTANCE` extra times
2159
2210
  // before letting the error bubble up to the outer (URL-rotating)
@@ -2167,7 +2218,14 @@ class SignallingService extends SubService {
2167
2218
  // burning a second reservation. If WS still fails after these
2168
2219
  // extra attempts, the instance is genuinely unusable and we
2169
2220
  // accept the cost of rotating.
2170
- this.connectToCirrus(wsUrl ?? '').pipe(filter(Truthy), take(1), retry({
2221
+ // POLLING flow only: the polled `signallingServer` IS Cirrus (single
2222
+ // socket), so the orchestration-issued connection id rides on this
2223
+ // upgrade URL. In the new WS flow this socket is orchestration-only
2224
+ // control and `streamRequestId` is still unknown here (it arrives later
2225
+ // in `instanceReserved`/`instanceReady`) → no-op, URL unchanged; the id
2226
+ // is attached to the machine address instead, see
2227
+ // `connectToStreamMachine`.
2228
+ this.connectToCirrus(withStreamRequestId(wsUrl ?? '', streamRequestId)).pipe(filter(Truthy), take(1), retry({
2171
2229
  count: WS_RETRY_PER_INSTANCE,
2172
2230
  delay: (err, attempt) => {
2173
2231
  this.store.dispatch(signalingRetryAttempted({
@@ -2198,9 +2256,18 @@ class SignallingService extends SubService {
2198
2256
  },
2199
2257
  }));
2200
2258
  }
2259
+ /**
2260
+ * Status message for the connect branch. It does NOT dispatch
2261
+ * `setEstablishingConnection` — `UnrealEffects.connectToSignaling$` already
2262
+ * emits that once per logical attempt via `startWith(...)`, covering BOTH
2263
+ * branches (prefetch adoption and a fresh connect). Dispatching it here as
2264
+ * well fired the boundary twice on every cold start, and since
2265
+ * `StreamStatusTelemetryService2` starts a session on that boundary, the
2266
+ * first session was killed 2 ms in as `restart` — losing the only honest
2267
+ * page-load measurement and the prefetch adoption with it.
2268
+ */
2201
2269
  startEstablishingConnection() {
2202
2270
  this.showStatusMessage(UnrealStatusMessage.STARTING_YOUR_SESSION);
2203
- this.store.dispatch(setEstablishingConnection({ value: true }));
2204
2271
  }
2205
2272
  adaptUrlsToRegion(urlList, region) {
2206
2273
  return urlList.map((url) => {
@@ -2254,6 +2321,9 @@ class SignallingService extends SubService {
2254
2321
  wsUrl: httpUrlToWs(`${location.protocol}//${data.signallingServer}`),
2255
2322
  pollingUrl: url,
2256
2323
  instanceName: data.signallingServer ?? '',
2324
+ // The orchestration issues the connection id here; it is handed to
2325
+ // Cirrus on the WS upgrade URL (see `connectAttempt`).
2326
+ streamRequestId: data.streamRequestId,
2257
2327
  })), finalize(() => {
2258
2328
  this.commandTelemetryService.trackStopCommand('EXT-getSignaling', true, { ...(lastResp ?? {}), multi: true });
2259
2329
  this.store.dispatch(pollingEnded());
@@ -2371,8 +2441,15 @@ class SignallingService extends SubService {
2371
2441
  *
2372
2442
  * "Just connect" — nothing is sent on open (the machine is already reserved
2373
2443
  * for this session): Cirrus pushes `config` and Unreal sends the `offer`.
2444
+ *
2445
+ * The orchestration-issued `streamRequestId` (from the same `instanceReady`
2446
+ * payload) rides on this upgrade URL: in the new flow THIS is the Cirrus
2447
+ * socket, so it is the one that must carry the id (the first socket is
2448
+ * orchestration-only control and needs nothing). Without it the stream
2449
+ * machine's Cirrus falls back to its own `guid()` and the session can no
2450
+ * longer be correlated. Absent id → URL unchanged.
2374
2451
  */
2375
- connectToStreamMachine(originInstanceUri) {
2452
+ connectToStreamMachine(originInstanceUri, streamRequestId) {
2376
2453
  if (!originInstanceUri) {
2377
2454
  Logger.warn('instanceReady without originInstanceUri — stream socket not opened');
2378
2455
  return;
@@ -2387,7 +2464,7 @@ class SignallingService extends SubService {
2387
2464
  // before a fresh attempt — e.g. a retry that resolved to a different machine
2388
2465
  // or two instanceReady messages before the first connect resolves.
2389
2466
  this.teardownStreamWs();
2390
- this.streamWsSub = createWebSocket(originInstanceUri)
2467
+ this.streamWsSub = createWebSocket(withStreamRequestId(originInstanceUri, streamRequestId))
2391
2468
  .pipe(takeUntil(this.abort$), takeUntil(this.destroy$), take(1))
2392
2469
  .subscribe({
2393
2470
  next: (ws) => {
@@ -3688,11 +3765,12 @@ class StreamAdoptionService {
3688
3765
  *
3689
3766
  * Wrapped in `defer` so the `tryAdopt()` side effect (which dispatches
3690
3767
  * `streamAdopted`) runs at SUBSCRIPTION time, not when the observable is
3691
- * built. The effect prepends `startWith(setEstablishingConnection(true))`,
3692
- * which starts the telemetry session and subscribes its phase catalog; that
3693
- * must happen BEFORE `streamAdopted` → `regionTelemetryOnAdopt$` replays
3694
- * `regionResolved`, or the region phase fires into a session that doesn't
3695
- * exist yet and is lost. `defer` guarantees that ordering.
3768
+ * built. The effect prepends `startWith(setEstablishingConnection(true))` —
3769
+ * the single dispatch site for that boundary — which starts the telemetry
3770
+ * session and subscribes its phase catalog; that must happen BEFORE
3771
+ * `streamAdopted`, or `emitAdoptedConnection` fires into a session that does
3772
+ * not exist yet and the whole connection waterfall is lost. `defer`
3773
+ * guarantees that ordering.
3696
3774
  */
3697
3775
  awaitAndAdopt$() {
3698
3776
  return defer(() => {
@@ -3762,6 +3840,7 @@ class StreamAdoptionService {
3762
3840
  this.store.dispatch(streamAdopted({
3763
3841
  machineId: parked.machineId ?? null,
3764
3842
  ssData: parked.ssData ?? null,
3843
+ streamRequestId: parked.ids?.streamRequestId || null,
3765
3844
  timings: parked.timings ?? null,
3766
3845
  }));
3767
3846
  // Hand the live socket over without re-sending `requestStream`, reusing the
@@ -4063,9 +4142,12 @@ class UnrealEffects {
4063
4142
  return this.actions$.pipe(ofType(setAwsInstance),
4064
4143
  // require wsUrl present on the action payload
4065
4144
  filter((action) => !!action.wsUrl),
4066
- // for each awsInstance action capture one ssData snapshot, then wait for dataChannelConnected
4145
+ // for each awsInstance action capture one connectionId snapshot, then wait
4146
+ // for dataChannelConnected. The gate stays on `ssInfo` (unchanged timing);
4147
+ // the id itself now comes from the orchestration when it issued one, and
4148
+ // falls back to the `ssInfo` value otherwise.
4067
4149
  switchMap((awsInstance) => {
4068
- return this.actions$.pipe(ofType(updateCirrusInfo), take(1), switchMap(({ ssData }) => {
4150
+ return this.actions$.pipe(ofType(updateCirrusInfo), take(1), withLatestFrom(this.store.select(selectCirrusConnectionId)), switchMap(([, connectionId]) => {
4069
4151
  const abort$ = this.actions$.pipe(ofType(setCirrusDisconnected, // ← "Cirrus closed" action — direct,
4070
4152
  // no fan-in tick delay
4071
4153
  abortEstablishingConnection), take(1));
@@ -4082,7 +4164,7 @@ class UnrealEffects {
4082
4164
  Logger.error(error);
4083
4165
  const body = {
4084
4166
  awsInstance,
4085
- connectionId: ssData?.connectionId,
4167
+ connectionId,
4086
4168
  href: location.href,
4087
4169
  userAgent: navigator.userAgent,
4088
4170
  error,
@@ -5880,10 +5962,14 @@ class StreamStatusTelemetryService2 {
5880
5962
  // CLOSER — the `regionResolved` action carries the raw ping
5881
5963
  // samples (one per probe) from `RegionsPingService`; the
5882
5964
  // catalog flattens them to a comma-separated string since
5883
- // `toPhaseParams` only accepts primitives. Adopted connections never
5884
- // run `getRegion`, but `UnrealEffects.regionTelemetryOnAdopt$` replays
5885
- // the region waterfall (start → end → resolved) from the cached winner,
5886
- // so this phase still appears with its ping samples.
5965
+ // `toPhaseParams` only accepts primitives.
5966
+ //
5967
+ // Adopted connections never run `getRegion`, so this closer never
5968
+ // fires for them (and is suppressed anyway via PREFETCHED_PHASE_IDS).
5969
+ // Their region phase comes from {@link emitAdoptedConnection}, which
5970
+ // reports `duration: 0`, no milestones, and reads the winner + samples
5971
+ // out of the ping cache — so it is EMPTY when the region-ping prefetch
5972
+ // never ran or its cache expired.
5887
5973
  {
5888
5974
  id: 'region-resolved',
5889
5975
  event: this.actions$.pipe(ofType(regionResolved), take(1), map(({ region, pingsMs }) => ({
@@ -6053,12 +6139,17 @@ class StreamStatusTelemetryService2 {
6053
6139
  // times per page load (the unreal-scene effect, `startStream`, the
6054
6140
  // reconnect effect), but the connection layer collapses them into one
6055
6141
  // attempt via `connectToSignaling$` (`exhaustMap` + `!cirrusConnected`
6056
- // filter). `setEstablishingConnection(true)` is dispatched exactly
6057
- // once per real attempt from `SignallingService.startEstablishingConnection()`,
6058
- // downstream of that guard — so binding here yields one session per
6059
- // attempt instead of a burst of `restart` churn sessions. Genuine
6142
+ // filter). `setEstablishingConnection(true)` is dispatched exactly once
6143
+ // per real attempt, by that effect's `startWith(...)` — the SINGLE
6144
+ // dispatch site, downstream of the guard and ahead of both branches
6145
+ // (prefetch adoption / fresh connect). So binding here yields one session
6146
+ // per attempt instead of a burst of `restart` churn sessions. Genuine
6060
6147
  // reconnects (e.g. DataChannelTimeout) re-enter the same path and
6061
6148
  // correctly end the prior session as `restart` + start a new one.
6149
+ //
6150
+ // Do NOT add a second dispatch site: this binding turns every extra
6151
+ // `true` into `endSession('restart')` + a new session, which throws away
6152
+ // the first session's page-load measurement and its prefetch adoption.
6062
6153
  this.actions$
6063
6154
  .pipe(ofType(setEstablishingConnection), filter$1(({ value }) => value === true), takeUntil$1(this.destroy$))
6064
6155
  .subscribe(() => this.startSession());