Skip to content

feat(websockets): migrate WebSocket stack to data-access and react to site CRUD events#35378

Open
fmontes wants to merge 37 commits intomainfrom
fix-websockets-v3
Open

feat(websockets): migrate WebSocket stack to data-access and react to site CRUD events#35378
fmontes wants to merge 37 commits intomainfrom
fix-websockets-v3

Conversation

@fmontes
Copy link
Copy Markdown
Member

@fmontes fmontes commented Apr 17, 2026

TL;DR

Builds a new native WebSocket stack in @dotcms/data-access, wires it into the app to react to site CRUD events and toolbar navigation, then removes the entire legacy DotcmsEventsService / dotcms-js WebSocket layer — so only one connection to /api/ws/v1/system/events ever runs.


What changed

New WebSocket stack (libs/data-access + libs/global-store)

  • DotEventsSocket — native WebSocket only (long-polling dropped; all modern browsers support WS), exponential backoff with jitter, typed on<T>(eventType) API, reactive status$() stream
  • withWebSocket() NgRx signal store feature — manages connection lifecycle at app init via provideAppInitializer, exposes wsStatus signal and typed event observables: switchSiteEvent$, portletLayoutUpdated$, siteEvents$

Site CRUD reactions

  • DotSiteComponent debounces list refresh on any site WebSocket event; auto-switches to default site when the selected site is archived, stopped, or deleted
  • Java SystemEventType.UN_PUBLISH_SITE added — was missing, causing stop-site events to be silently swallowed
  • Toolbar reacts to SWITCH_SITE event via DotSiteNavigationEffect (extracted from toolbar component)

Migration of all DotcmsEventsService consumers

Replaced subscribeTo() / subscribeToEvents() with DotEventsSocket.on<T>() or GlobalStore event streams in:

  • DotMessageDisplayService
  • DotToolbarNotificationsComponent (also fixed missing takeUntilDestroyed memory leak)
  • DotLargeMessageDisplayComponent
  • IframeComponent / IframePortletLegacyComponent
  • LoginServiceSESSION_DESTROYED / SESSION_LOGOUT
  • RoutingServiceUPDATE_PORTLET_LAYOUTS
  • DotPluginsListStoreOSGI_FRAMEWORK_RESTART / OSGI_BUNDLES_LOADED / OSGI_BUNDLES_UPLOAD_FAILED
  • SiteService — event subscriptions removed (data API and class kept; @deprecated)

Deleted — entire legacy WebSocket layer

  • DotcmsEventsService and its subscribeTo() / subscribeToEvents() API
  • DotEventsSocket (dotcms-js version), DotEventsSocketURL injection token, dotEventSocketURLFactory
  • WebSocketProtocol, LongPollingProtocol, Protocol, DotWebSocketConfig
  • DotcmsEventsServiceMock from @dotcms/utils-testing
  • All associated spec files and barrel exports from @dotcms/dotcms-js
  • ~60 spec files cleaned of dead provider mocks

Test plan

  • WebSocket connects on app load; no status indicator shown when connected
  • Stop/archive/delete the currently selected site — site selector switches to default site
  • Publish/update a site — site list refreshes (debounced ~300ms)
  • Navigate portlets — menu reloads on UPDATE_PORTLET_LAYOUTS event
  • Kill and restart dotCMS server — UI reconnects automatically (reconnecting indicator visible)
  • yarn nx test data-access passes
  • yarn nx test global-store passes
  • yarn nx test portlets-dot-plugins-portlet passes

🤖 Generated with Claude Code

fmontes and others added 30 commits March 14, 2026 22:22
… site CRUD events

- Move WebSocket service from deprecated dotcms-js to libs/data-access as DotEventsSocket
  using native WebSocket only (dropped long-polling — all modern browsers support WS)
- Add withWebSocket() feature to GlobalStore managing connection lifecycle, wsStatus signal,
  portletLayoutUpdated and siteEvents observables
- Replace DotcmsEventsService.subscribeTo('UPDATE_PORTLET_LAYOUTS') in DotNavigationService
  with globalStore.portletLayoutUpdated
- React to site CRUD events (SAVE, PUBLISH, UN_PUBLISH, UPDATE, ARCHIVE, UN_ARCHIVE, DELETE)
  in DotSiteComponent: debounce list refresh, switch to default site when selected site
  becomes unavailable (archived, stopped, deleted)
- Add UN_PUBLISH_SITE to SystemEventType so unpublishHost DWR fires the WebSocket event

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix timer subscription leak in scheduleReconnect() — store subscription
  in reconnectTimer and cancel on destroy()
- Guard openSocket() against concurrent calls when socket is CONNECTING
- Remove dead getLongPollingURL() method from DotEventsSocketURL
- Update spec mock to remove getLongPollingURL reference
- Remove invalid test in with-websocket.feature.spec.ts that assumed rxMethod
  would propagate errors and set wsStatus to closed (rxMethod swallows errors
  and connect() completes immediately, never throws)
- Add dot-events-socket.service.spec.ts (was untracked)
- Fix stale JSDoc in refreshSelectedSite() — "archive/un-archive" → "unavailable events"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the siteListRefresh$ Subject + two subscriptions pattern with a
single pipe using tap + debounceTime directly on the merged site events
observable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t mock

- Add 'WebSocket site events' describe block to dot-site.component.spec.ts
  covering: debounced list refresh, re-fetch on update, no-op for other
  sites, switch-to-default on ARCHIVE/UN_PUBLISH/DELETE, error fallbacks,
  and unsubscribe on destroy
- Add mockProvider(DotEventsSocket) to dot-site.component.spec.ts factories
- Fix dot-theme.component.spec.ts: add mockProvider(DotEventsSocket) since
  DotThemeComponent embeds DotSiteComponent which now injects DotEventsSocket

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ing token

The class existed only to build a ws/wss URL from hostname + SSL flag.
Replace with InjectionToken<string> — the factory in providers.ts returns
the URL string directly, and DotEventsSocket injects it as a plain string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ion.host

Use window.location.host (hostname + port combined) instead of manually
concatenating hostname and port separately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DotEventsSocket now derives its URL directly from window.location — no
injection token or external configuration needed. Removes
DOT_EVENTS_SOCKET_URL token, DotEventsSocketURL file, and the factory
in providers.ts entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add switchSiteEvent$() to withWebSocket feature — emits the new DotSite
  from the SWITCH_SITE payload (no HTTP call needed)
- DotToolbarComponent now uses globalStore.siteDetails as $currentSite
  (signal) instead of a one-shot getCurrentSite() observable
- On SWITCH_SITE: call setCurrentSite() and navigate away from edit page
- Remove stale ARCHIVE_SITE handler and DotcmsEventsService dependency
  (archive is already handled by DotSiteComponent directly)
- Update toolbar spec: add SWITCH_SITE tests, remove dotcms-js imports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The toolbar was acting as a mediator — calling DotSiteService directly
and then writing back to the store it was reading from. Move that
responsibility where it belongs:

- Add switchCurrentSite(identifier) to GlobalStore: calls switchSite()
  → getCurrentSite() → patchState internally
- Handle SWITCH_SITE WebSocket event inside store onInit instead of
  the toolbar, removing the need for setCurrentSite() entirely
- DotToolbarComponent now delegates to globalStore.switchCurrentSite()
  and only owns the navigation side effect (goToSiteBrowser)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ionEffect

Navigation away from the edit page on site switch is an app-level
concern, not a toolbar concern. Move it into a dedicated effect service
that lives for the full application lifetime:

- Add DotSiteNavigationEffect: subscribes to switchSiteEvent$() and
  calls goToSiteBrowser() when on edit page
- Register it eagerly via providers.ts (providedIn root + listed)
- DotToolbarComponent is now a pure UI component with no lifecycle hooks
- Add DotSiteNavigationEffect.spec.ts covering both navigation paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…teNavigationEffect

DotSiteNavigationEffect already handles goToSiteBrowser() when a SWITCH_SITE
WebSocket event fires, which includes user-initiated switches from the toolbar.
Removing the duplicated check keeps navigation responsibility in one place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…obalStore.switchSiteEvent$

Replace all usages of the legacy dotcms-js SiteService.switchSite$ observable in
apps/dotcms-ui with GlobalStore.switchSiteEvent$, which is backed by the WebSocket
SWITCH_SITE event. This consolidates site-switch reactivity through the global store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migrate all subscribeTo/subscribeToEvents call sites to the new
DotEventsSocket.on<T>() API. Also fixes a memory leak in
DotToolbarNotificationsComponent by adding takeUntilDestroyed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Wire switchSiteEvent$ mock before service creation in DotSiteNavigationEffect
  spec so the constructor subscription does not receive undefined
- Fix GlobalStore SWITCH_SITE test: move mock setup into beforeEach before
  createService so onInit subscribes to the correct subject
- Remove goToSiteBrowser assertion from toolbar test — navigation now lives
  in DotSiteNavigationEffect, not the toolbar component
- Fix misleading onclose comment: 1001 is going-away (tab close), not 1000

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace SiteService.switchSite$ with GlobalStore.switchSiteEvent$() in
the component test, matching the production code change on this branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-switch

Replace SiteService.switchSite$ with GlobalStore.switchSiteEvent$() in
the component test, matching the production code change on this branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d DotEventsSocket teardown error

The real GlobalStore includes withWebSocket() whose onDestroy calls
inject(DotEventsSocket) outside an injection context during test cleanup.
DotSubNavComponent doesn't use GlobalStore at all, so mocking it is correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…vent NG0203 in tests

`inject()` called as a default parameter in `onDestroy` fires during
R3Injector.destroy(), after the injection context is gone. Capture the
DotEventsSocket reference in withMethods() via a new destroySocket()
method and call that from onDestroy() instead.

Also adds DotEventsSocket mock to dot-navigation.service.spec.ts so
GlobalStore can initialize withWebSocket without opening a real socket.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move createServiceFactory to describe level (Spectator requirement) and
override GlobalStore per-test via createService() options so each test
gets a fresh Subject for switchSiteEvent$.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…itch

Replace SiteService.switchSite$ with GlobalStore.switchSiteEvent$() in
the component test, matching the production code change on this branch.
Update site-switch assertion to expect paginatorService.getFirstPage()
instead of get() since getContainersByHost calls getFirstPage().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Template was still referencing the old public `globalStore.siteDetails()`
property after the store was made private. Update to use the `$currentSite`
signal alias exposed on the component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ockWebSocket static constants

- store.spec.ts: move createServiceFactory to describe level to avoid
  Spectator "Hooks cannot be defined inside tests" error
- dot-events-socket.service.spec.ts: add static CONNECTING/OPEN/CLOSING/CLOSED
  constants to MockWebSocket so the guard in openSocket() doesn't false-match
  when global.WebSocket is replaced with the mock (undefined === undefined)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ing Math.random

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…uracy, and mock cleanup

- container-list: pipe switchSiteEvent$ through takeUntil(destroy$) to prevent subscription leak on destroy
- providers: use provideAppInitializer to force DotSiteNavigationEffect instantiation at startup
- dot-site-navigation.effect.spec: remove redundant describe-level mock; clarify beforeEach ordering
- dot-events-socket.service: clarify onclose comment (1001 vs 1000 distinction)
- dot-events-socket.service.spec: rename test to match what it actually asserts
- store.spec: add comment explaining beforeEach ordering; fix switchSite mock type
- dot-site.component.spec: restore DotEventsSocket per-event subject wiring (was commented out)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… dual WebSocket connections

Migrates the last 4 consumers to use DotEventsSocket from @dotcms/data-access
or GlobalStore event streams, then deletes the entire legacy WebSocket layer.

- LoginService: SESSION_DESTROYED/SESSION_LOGOUT now wired to DotEventsSocket directly
- RoutingService: UPDATE_PORTLET_LAYOUTS now wired to DotEventsSocket (avoids circular dep)
- SiteService: event subscriptions removed (data API unchanged); deprecated class kept
- DotPluginsListStore: OSGI events migrated from DotcmsEventsService to DotEventsSocket
- Removed DotcmsEventsService, DotEventsSocket (legacy), DotEventsSocketURL from all provider arrays
- Deleted: dotcms-events.service.ts, dot-event-socket.ts, long-polling-protocol.ts,
  websockets-protocol.ts, dot-event-socket-url model, and all related spec files
- Cleaned barrel exports in dotcms-js/src/public_api.ts
- Updated ~60 spec files to remove dead provider mocks for the deleted services

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fmontes and others added 3 commits April 17, 2026 11:55
…tsSocket import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t import

dotcms-js → data-access is a known architectural constraint; the circular
dep lint rule fires because data-access re-exports symbols that import
back into dotcms-js. Suppress on these two lines only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

❌ Issue Linking Required

This PR could not be linked to an issue. All PRs must be linked to an issue for tracking purposes.

How to fix this:

Option 1: Add keyword to PR body (Recommended - auto-removes this comment)
Edit this PR description and add one of these lines:

  • This PR fixes #123 or Fixes: #123

  • This PR closes #123 or Closes: #123

  • This PR resolves #123 or Resolves: #123

  • Other supported keywords: fix, fixed, close, closed, resolve, resolved
    Option 2: Link via GitHub UI (Note: won't clear the failed check)

  1. Go to the PR → Development section (right sidebar)

  2. Click "Link issue" and select an existing issue

  3. Push a new commit or re-run the workflow to clear the failed check
    Option 3: Use branch naming
    Create a new branch with one of these patterns:

  • 123-feature-description (number at start)

  • issue-123-feature-description (issue-number at start)

  • feature-issue-123 (issue-number anywhere)

Why is this required?

Issue linking ensures proper tracking, documentation, and helps maintain project history. It connects your code changes to the problem they solve.---

This comment was automatically generated by the issue linking workflow

@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 17, 2026

Claude finished @fmontes's task in 2m 37s —— View job


PR Review

Large, well-structured migration. The core WebSocket architecture is sound. Issues below are ordered by severity.


🔴 switchCurrentSite swallows errors permanently

store.ts lines 149–158: switchCurrentSite chains with tap but has no catchError or tapResponse. If either switchSite() or the subsequent getCurrentSite() throws (network hiccup, 403, etc.), the rxMethod's inner observable completes with an error and is not retried — all future calls to switchCurrentSite() silently do nothing until the store is re-created.

Compare to loadCurrentSite on lines 125–141 which correctly wraps in tapResponse. Same fix needed here.

switchCurrentSite: rxMethod<string>(
    pipe(
        switchMap((identifier) =>
            siteService.switchSite(identifier).pipe(
                switchMap(() => siteService.getCurrentSite()),
                tapResponse({
                    next: (siteDetails) => patchState(store, { siteDetails }),
                    error: (err) => console.warn('Error switching site:', err)
                })
            )
        )
    )
)

Fix this →


🔴 syncSiteOnSwitchEvent can write undefined into the store

with-websocket.feature.ts line 92: switchSiteEvent$() calls eventsSocket.on<DotSite>('SWITCH_SITE'), which maps msg.payload?.data as DotSite. If a SWITCH_SITE event arrives without payload.data (malformed event, or a future backend change), the cast silently emits undefined. Then syncSiteOnSwitchEvent calls:

tap((site) => patchState(store, { siteDetails: site }))

This overwrites siteDetails with undefined, breaking the DotSite | null contract. Every downstream caller that does siteDetails()?.identifier survives, but code doing siteDetails()! or reading fields directly will throw.

Add a guard: either filter((site): site is DotSite => site != null) in switchSiteEvent$(), or a null-check before patchState in syncSiteOnSwitchEvent.

Fix this →


🟡 DotNavigationService — new portletLayoutUpdated$() subscription has no teardown

dot-navigation.service.ts lines 82–96 (new in this PR):

this.#globalStore
    .portletLayoutUpdated$()
    .pipe(switchMap(() => this.dotMenuService.reloadMenu().pipe(take(1))))
    .subscribe(...);

No takeUntilDestroyed() or DestroyRef cleanup. The other constructor subscriptions on lines 33, 48, and 98 are pre-existing patterns, but this one is new. In tests that reinstantiate DotNavigationService via TestBed, each run adds a lingering subscription to the menu reload stream.

Fix this →


🟡 providers.tsDotSiteNavigationEffect registered twice

Lines 143–144:

DotSiteNavigationEffect,
provideAppInitializer(() => void inject(DotSiteNavigationEffect))

DotSiteNavigationEffect is providedIn: 'root', so it's a singleton. Listing it in the PROVIDERS array forces eager instantiation — the provideAppInitializer then does the same thing redundantly. The constructor runs once, the subscription is created once, so this isn't a bug. But the intent is unclear — either the plain listing or the provideAppInitializer is the real mechanism; having both makes it hard to reason about. Consider keeping only provideAppInitializer.


🟡 DotcmsEventsService.subscribeToEvents inner subscription leak (acknowledged, but worth tracking)

dotcms-events.service.ts lines 42–51: Each call to subscribeToEvents() creates N inner subscriptions via this.subscribeTo<T>(eventType).subscribe(...) that are never unsubscribed when the returned Observable is unsubscribed. The only active caller that matters here is LoginService which uses subscribeTo() (not subscribeToEvents()), so there's no live leak right now. But any code that still calls subscribeToEvents() will leak on every call. Confirmed this is deferred to a cleanup ticket — just flagging that the leak accumulates, not just persists.


ℹ️ Minor observations (no action needed)

  • on<T>() type cast (dot-events-socket.service.ts line 70): msg.payload?.data as T where T is void or undefined-tolerant is fine for the existing usages. The guard issue above is the only dangerous case.
  • withWebSocket spec doesn't mock messages() (with-websocket.feature.spec.ts): feedLegacyEventBus calls eventsSocket.messages() in onInit, but the mock only provides on. This means feedLegacyEventBus will throw mockEventsSocket.messages is not a function unless the mock also mocks messages. Either the spec is missing this, or feedLegacyEventBus fails silently in tests. Worth verifying yarn nx test global-store still passes.
  • SWITCH_SITE feedback loop: switchCurrentSite() → calls siteService.switchSite() → server may fire a SWITCH_SITE WS event → syncSiteOnSwitchEvent sets siteDetails again to the same value. Redundant but not incorrect, assuming the backend sends the same site that was just switched to.

@github-actions github-actions bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels Apr 17, 2026
@fmontes fmontes force-pushed the fix-websockets-v3 branch from b4312a9 to 4619987 Compare April 17, 2026 21:17
fmontes and others added 2 commits April 17, 2026 15:32
…nd fix TypeScript type in withWebSocket

- Add DotcmsEventsService to public_api.ts so @dotcms/dotcms-js resolves it
- Explicit type annotation on dotcmsEventsService inject() call to satisfy strict TS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates the admin UI’s system-events WebSocket integration from the legacy @dotcms/dotcms-js stack to a new native DotEventsSocket in @dotcms/data-access, then wires app behavior (site CRUD + navigation/menu refresh) off typed socket streams and GlobalStore helpers to ensure only one /api/ws/v1/system/events connection is active.

Changes:

  • Added a new native WebSocket client (DotEventsSocket) in @dotcms/data-access with reconnection + typed on<T>() API.
  • Introduced withWebSocket() GlobalStore feature to own socket lifecycle + expose common event streams (switchSiteEvent$, portletLayoutUpdated$, etc.).
  • Removed legacy WebSocket/long-polling protocol layer and updated app/services/specs to use the new socket/global-store streams; added missing backend UN_PUBLISH_SITE enum.

Reviewed changes

Copilot reviewed 93 out of 93 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
dotCMS/src/main/java/com/dotcms/api/system/event/SystemEventType.java Adds missing site stop/unpublish system event enum.
core-web/libs/utils-testing/src/lib/dotcms-events-service.mock.ts Removes legacy events-service mock.
core-web/libs/utils-testing/src/index.ts Stops exporting removed legacy events-service mock.
core-web/libs/ui/src/lib/components/dot-theme/dot-theme.component.spec.ts Updates tests to mock DotEventsSocket instead of legacy events service.
core-web/libs/ui/src/lib/components/dot-site/dot-site.component.ts Switches site selector refresh logic to DotEventsSocket and handles unavailable-site events.
core-web/libs/ui/src/lib/components/dot-site/dot-site.component.spec.ts Adds coverage for site WebSocket event reactions using DotEventsSocket mocks.
core-web/libs/template-builder/src/lib/components/template-builder/template-builder.component.spec.ts Removes legacy events service provider usage in tests.
core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-actions/template-builder-actions.component.spec.ts Removes legacy events service provider usage in tests.
core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-ema-workflow-actions/dot-ema-workflow-actions.service.spec.ts Removes legacy socket URL factory/provider from tests.
core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts Removes legacy events service mock/provider from tests.
core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts Removes legacy events service mock/provider from tests.
core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.spec.ts Removes legacy events service mock/provider from tests.
core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/store/dot-plugins-list.store.ts Migrates OSGi event subscriptions to DotEventsSocket.on().
core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/store/dot-plugins-list.store.spec.ts Updates store tests to mock DotEventsSocket.on().
core-web/libs/portlets/dot-plugins/src/lib/dot-plugins-list/dot-plugins-list.component.spec.ts Updates component tests to mock DotEventsSocket.
core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.spec.ts Removes legacy events service mock/provider from tests.
core-web/libs/global-store/src/lib/store.ts Adds withWebSocket() feature and new switchCurrentSite() method; syncs site on SWITCH_SITE events.
core-web/libs/global-store/src/lib/store.spec.ts Updates tests for new switchCurrentSite() and SWITCH_SITE socket syncing.
core-web/libs/global-store/src/lib/features/with-websocket/with-websocket.feature.ts New feature to start/destroy socket and expose common event observables.
core-web/libs/global-store/src/lib/features/with-websocket/with-websocket.feature.spec.ts Adds tests for withWebSocket() lifecycle + status signal updates.
core-web/libs/global-store/src/index.ts Re-exports WebSocketStatus for consumers.
core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts Updates GlobalStore mock API to remove setCurrentSite.
core-web/libs/dotcms-js/src/public_api.ts Removes exports for legacy websocket stack types.
core-web/libs/dotcms-js/src/lib/core/util/websockets-protocol.ts Deletes legacy WebSocket protocol implementation.
core-web/libs/dotcms-js/src/lib/core/util/websockets-protocol.spec.ts Deletes tests for legacy WebSocket protocol.
core-web/libs/dotcms-js/src/lib/core/util/models/dot-event-socket-url.ts Deletes legacy socket URL model.
core-web/libs/dotcms-js/src/lib/core/util/long-polling-protocol.ts Deletes legacy long-polling protocol.
core-web/libs/dotcms-js/src/lib/core/util/long-polling-protocol.spec.ts Deletes tests for legacy long-polling protocol.
core-web/libs/dotcms-js/src/lib/core/util/dot-event-socket.ts Deletes legacy event socket implementation.
core-web/libs/dotcms-js/src/lib/core/util/dot-event-socket.spec.ts Deletes tests for legacy event socket.
core-web/libs/dotcms-js/src/lib/core/site.service.ts Removes websocket-based site event wiring from legacy SiteService.
core-web/libs/dotcms-js/src/lib/core/login.service.ts Keeps legacy event subscriptions but removes socket start side-effect.
core-web/libs/dotcms-js/src/lib/core/dotcms-events.service.ts Refactors to a deprecated Subject-only bus fed externally (no socket logic).
core-web/libs/dot-rules/src/lib/rule-engine.module.ts Removes legacy events service from module providers.
core-web/libs/data-access/src/lib/dot-workflow-event-handler/dot-workflow-event-handler.service.spec.ts Removes legacy socket URL factory/provider from tests.
core-web/libs/data-access/src/lib/dot-websocket/dot-events-socket.service.ts Adds new native WebSocket client with reconnect + status stream.
core-web/libs/data-access/src/lib/dot-websocket/dot-events-socket.service.spec.ts Adds tests for DotEventsSocket connection, filtering, and reconnection behavior.
core-web/libs/data-access/src/lib/dot-websocket/dot-event-message.model.ts Adds typed model for raw system-event messages.
core-web/libs/data-access/src/lib/dot-message-display/dot-message-display.service.ts Migrates MESSAGE handling to DotEventsSocket.on().
core-web/libs/data-access/src/lib/dot-message-display/dot-message-display.service.spec.ts Updates tests to emit messages through DotEventsSocket mock.
core-web/libs/data-access/src/index.ts Exposes new websocket services/models from @dotcms/data-access.
core-web/apps/dotcms-ui/src/app/view/components/main-legacy/main-legacy.component.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/view/components/dot-workflow-task-detail/dot-workflow-task-detail.component.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.spec.ts Updates toolbar tests for GlobalStore site switching and new navigation effect wiring.
core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.ts Simplifies site switching to GlobalStore.switchCurrentSite() and removes legacy event wiring.
core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/dot-toolbar.component.html Binds site selector value via $currentSite() signal.
core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-notifications/dot-toolbar-notifications.component.ts Migrates NOTIFICATION subscription to DotEventsSocket.on() and bounds list size.
core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-notifications/components/dot-notification-item/dot-notification-item.component.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/view/components/dot-persona-selector/dot-persona-selector.component.spec.ts Removes legacy events service provider usage in tests.
core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.ts Moves UPDATE_PORTLET_LAYOUTS handling to GlobalStore portletLayoutUpdated$().
core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/services/dot-navigation.service.spec.ts Removes legacy events service mock and updates to new GlobalStore wiring.
core-web/apps/dotcms-ui/src/app/view/components/dot-navigation/components/dot-sub-nav/dot-sub-nav.component.spec.ts Adjusts GlobalStore provisioning in tests.
core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.ts Migrates LARGE_MESSAGE subscription to DotEventsSocket.on().
core-web/apps/dotcms-ui/src/app/view/components/dot-large-message-display/dot-large-message-display.component.spec.ts Updates tests to drive LARGE_MESSAGE via DotEventsSocket mock.
core-web/apps/dotcms-ui/src/app/view/components/dot-iframe-dialog/dot-iframe-dialog.component.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-reorder-menu/dot-reorder-menu.component.spec.ts Removes legacy events service provider usage in tests.
core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-create-contentlet/dot-create-contentlet.component.spec.ts Removes legacy events service mock/provider from tests.
core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-contentlet-wrapper/dot-contentlet-wrapper.component.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/view/components/dot-contentlet-editor/components/dot-add-contentlet/dot-add-contentlet.component.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/view/components/dot-add-persona-dialog/dot-add-persona-dialog.component.spec.ts Removes legacy events service provider usage in tests.
core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.ts Switches site-change reaction to GlobalStore and AI prompt to DotEventsSocket.
core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-porlet-legacy/iframe-porlet-legacy.component.spec.ts Updates tests to provide DotEventsSocket from data-access.
core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.ts Replaces legacy multi-event subscription with merged DotEventsSocket.on() streams.
core-web/apps/dotcms-ui/src/app/view/components/_common/iframe/iframe-component/iframe.component.spec.ts Updates tests to drive iframe reload events via DotEventsSocket mock subjects.
core-web/apps/dotcms-ui/src/app/view/components/_common/dot-wizard/dot-wizard.component.spec.ts Removes legacy events service provider usage in tests.
core-web/apps/dotcms-ui/src/app/test/dot-test-bed.ts Removes legacy socket URL factory and legacy websocket providers from shared test bed.
core-web/apps/dotcms-ui/src/app/shared/shared.module.ts Removes legacy websocket providers from shared module.
core-web/apps/dotcms-ui/src/app/providers.ts Removes legacy websocket providers; eagerly provides DotSiteNavigationEffect.
core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/components/dot-content-type-copy-dialog/dot-content-type-copy-dialog.component.spec.ts Removes legacy events service provider usage in tests.
core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/layout/content-types-layout.component.spec.ts Removes legacy events service provider usage in tests.
core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/form/content-types-form.component.spec.ts Removes legacy events service provider usage in tests.
core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/fields/content-type-fields-drop-zone/content-type-fields-drop-zone.component.spec.ts Removes legacy DotEventsSocket (dotcms-js) from test providers.
core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.ts Switches site-change listener to GlobalStore switchSiteEvent$().
core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-list/dot-template-list.component.spec.ts Updates tests to trigger site changes via GlobalStore stream.
core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-props/dot-template-props.component.spec.ts Removes legacy events service provider usage in tests.
core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.ts Switches site-change listener to GlobalStore switchSiteEvent$().
core-web/apps/dotcms-ui/src/app/portlets/dot-templates/dot-template-create-edit/dot-template-create-edit.component.spec.ts Updates tests to trigger site changes via GlobalStore stream.
core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-workflow-task/dot-workflow-task.component.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-portlet-detail.component.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/portlets/dot-porlet-detail/dot-contentlets/dot-contentlets.component.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-table/dot-pages-table.component.spec.ts Removes legacy events service provider usage in tests.
core-web/apps/dotcms-ui/src/app/portlets/dot-pages/dot-pages-store/dot-pages.store.spec.ts Removes legacy events service mock/provider from tests.
core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-properties/dot-container-properties.component.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-create.component.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/portlets/dot-containers/dot-container-create/dot-container-code/dot-add-variable/dot-add-variable.component.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.ts Switches site-change listener to GlobalStore switchSiteEvent$().
core-web/apps/dotcms-ui/src/app/portlets/dot-containers/container-list/container-list.component.spec.ts Updates tests to trigger site changes via GlobalStore stream.
core-web/apps/dotcms-ui/src/app/api/services/dot-site-navigation/dot-site-navigation.effect.ts Adds app-level effect to navigate away from edit page on SWITCH_SITE events.
core-web/apps/dotcms-ui/src/app/api/services/dot-site-navigation/dot-site-navigation.effect.spec.ts Adds tests for edit-page navigation behavior on SWITCH_SITE.
core-web/apps/dotcms-ui/src/app/api/services/dot-custom-event-handler/dot-custom-event-handler.service.spec.ts Removes legacy events socket/providers from tests.
core-web/apps/dotcdn/src/app/app.module.ts Removes legacy websocket providers from dotcdn app module.

Comment on lines +45 to +95
connect(): Observable<void> {
return new Observable<void>((subscriber) => {
this.destroyed = false;
this.openSocket();
subscriber.next();
subscriber.complete();
});
}

/** Closes the connection permanently (e.g. on logout). */
destroy(): void {
this.destroyed = true;
this.reconnectTimer?.unsubscribe();
this.reconnectTimer = null;
this.setStatus('closed');
this.socket?.close();
this.socket = null;
}

/** Emits the typed payload of a specific event type. */
on<T>(eventType: string): Observable<T> {
return this._message.asObservable().pipe(
filter((msg) => msg.event === eventType),
map((msg) => msg.payload?.data as T)
);
}

/** All raw messages from the server. */
messages(): Observable<DotEventMessage> {
return this._message.asObservable();
}

isConnected(): boolean {
return this.status === 'connected';
}

/** Emits only when the status actually changes. */
status$(): Observable<WebSocketStatus> {
return this._status.asObservable().pipe(distinctUntilChanged());
}

private openSocket(): void {
if (this.destroyed || this.socket?.readyState === WebSocket.CONNECTING) {
return;
}

this.setStatus(this.retryCount === 0 ? 'connecting' : 'reconnecting');

try {
this.socket = new WebSocket(this.socketURL);
} catch {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c62d7be on branch claude/review-pr-comments-4tBxM. openSocket() now early-returns when readyState is CONNECTING or OPEN, and connect() cancels any pending reconnect timer before re-opening. CLOSING still falls through and onclosescheduleReconnect handles it.


Generated by Claude Code

Comment on lines +305 to +309
const tagEvent = (event: string) =>
this.eventsSocket
.on<{ identifier: string }>(event)
.pipe(map((data) => ({ ...data, event })));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fixing. {...undefined} is a no-op spread in ES2018+ — it doesn't throw, it produces {}. The resulting object { event: 'SAVE_SITE' } is then handled safely downstream in refreshSelectedSite which already uses siteData?.identifier !== pinned.identifier (optional chaining) and early-returns on mismatch. So an unexpected payload shape just becomes a no-op, not a crash.

The underlying type concern (payload not always present) is addressed separately by making DotEventMessage.payload optional in c62d7be.


Generated by Claude Code

Comment on lines +165 to +171
onInit(store) {
// Load current site on store initialization
// System configuration is automatically loaded by withSystem feature
store.loadCurrentSite();
// Keep siteDetails in sync when another user/tab switches the site
store
.switchSiteEvent$()
.pipe(tap((site) => patchState(store, { siteDetails: site })))
.subscribe();
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c62d7be. Converted the bare .subscribe() in GlobalStore.onInit into a syncSiteOnSwitchEvent rxMethod (same pattern as loadCurrentSite / trackStatus). Lifetime is now tied to the store — no manual teardown needed, and it's cleaned up between test module teardowns.


Generated by Claude Code

Comment on lines 42 to 49
subscribeToEvents<T>(clientEventTypes: string[]): Observable<DotEventTypeWrapper<T>> {
const subject: Subject<DotEventTypeWrapper<T>> = new Subject<DotEventTypeWrapper<T>>();
const subject = new Subject<DotEventTypeWrapper<T>>();

clientEventTypes.forEach((eventType: string) => {
this.subscribeTo(eventType).subscribe((data: T) => {
subject.next({
data: data,
name: eventType
});
clientEventTypes.forEach((eventType) => {
this.subscribeTo<T>(eventType).subscribe((data) => {
subject.next({ data, name: eventType });
});
});
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fixing in this PR. DotcmsEventsService is already marked @deprecated in this PR, and its only remaining consumers (LoginService, SiteService) are root-scoped / app-lifetime — the inner subscriptions don't leak in practice. Fully migrating LoginService off the legacy bus is explicit follow-up work per the PR description. Leaving as-is; worth tracking as a separate cleanup ticket.


Generated by Claude Code

fmontes pushed a commit that referenced this pull request Apr 18, 2026
…pilot

- DotEventsSocket: guard OPEN state in openSocket() and cancel pending
  reconnect timer on manual connect() so repeat calls can never spawn a
  second concurrent WebSocket (Copilot, Claude #4)
- dot-site.component: on switchSite(null) or getSiteById error, clear the
  CVA value via onSiteChange(null) instead of only nulling pinnedOption,
  so parent forms see the null value (Claude #1)
- DotEventMessage.payload: make payload and payload.data optional to
  match the defensive handling already in on<T>() (Claude #7)
- GlobalStore.onInit: replace bare .subscribe() for switchSiteEvent$()
  with a lifecycle-tracked rxMethod so the subscription is torn down
  with the store (Claude #3, Copilot)
- DotEventsSocket.messages(): document as @internal; consumers should
  use the typed on<T>() API (Claude #8)

https://claude.ai/code/session_01TYiVFjVWzBxdAnrJFZJv66
Copy link
Copy Markdown
Member Author

fmontes commented Apr 18, 2026

Review feedback triage — Claude + Copilot

Reviewed all comments from both bots. Fixes pushed to branch claude/review-pr-comments-4tBxM (commit c62d7be) — @fmontes feel free to merge/cherry-pick into fix-websockets-v3.

✅ Fixed

# Issue File Reviewer
1 openSocket() missed OPEN guard — could spawn a second WebSocket if connect() is called while one is already live; also now cancels any pending reconnect timer on manual connect() dot-events-socket.service.ts Copilot + Claude #4
2 On switchSite(null) / getSiteById failure, only pinnedOption was cleared — parent form had a stale identifier. Now routes through onSiteChange(null) so the CVA value fires null dot-site.component.ts Claude #1
3 DotEventMessage.payload is typed as required but on<T>() already handles it defensively — made payload? and payload.data? optional to match reality dot-event-message.model.ts Claude #7
4 GlobalStore.onInit had a bare .subscribe() on switchSiteEvent$() with no teardown. Converted to a lifecycle-tracked rxMethod (consistent with trackStatus / loadCurrentSite) store.ts Copilot + Claude #3
5 DotEventsSocket.messages() documented as @internal to discourage bypassing the typed on<T>() API (kept public since withWebSocket() in another package needs it for the legacy bus bridge) dot-events-socket.service.ts Claude #8

❌ Not fixing (out of scope / not a real issue)

  • Copilot — tagEvent() unsafe spread of undefined data: {...undefined} returns {} in ES2018+ (no throw), and refreshSelectedSite already guards siteData?.identifier !== pinned.identifier via optional chaining. Not a real bug.
  • Claude Test Branch and Commit #2 — filter changes dropped while load is in-flight: Pre-existing behavior in the lazy-load logic; not introduced by this PR. Better handled in a separate ticket if observed.
  • Claude wezell's issue #5 / test rwqrwqrwqr wq #6 and Copilot — DotcmsEventsService subscription leaks (LoginService, subscribeToEvents): The service is @deprecated and the listed consumers are root-scoped (app-lifetime). Fully migrating LoginService off the legacy bus is explicit follow-up work per the PR description. Leaving as-is; file as cleanup ticket.
  • Claude minor items (duplicated siteEvents$ merge logic, HARDCODED_TOTAL_ENTRIES = 501, DotNavigationService subscription): HARDCODED_TOTAL_ENTRIES already has a TODO awaiting API fix. The rest are stylistic refactors or pre-existing patterns — not blockers.

Verification

  • yarn nx test data-access ✅ 661 passed
  • yarn nx test global-store ✅ 174 passed
  • yarn nx test ui ✅ 652 passed
  • yarn nx lint data-access global-store ui ✅ clean (5 pre-existing warnings, 0 errors)

Generated by Claude Code

…pilot

- DotEventsSocket: guard OPEN state in openSocket() and cancel pending
  reconnect timer on manual connect() so repeat calls can never spawn a
  second concurrent WebSocket (Copilot, Claude #4)
- dot-site.component: on switchSite(null) or getSiteById error, clear the
  CVA value via onSiteChange(null) instead of only nulling pinnedOption,
  so parent forms see the null value (Claude #1)
- DotEventMessage.payload: make payload and payload.data optional to
  match the defensive handling already in on<T>() (Claude #7)
- GlobalStore.onInit: replace bare .subscribe() for switchSiteEvent$()
  with a lifecycle-tracked rxMethod so the subscription is torn down
  with the store (Claude #3, Copilot)
- DotEventsSocket.messages(): document as @internal; consumers should
  use the typed on<T>() API (Claude #8)

https://claude.ai/code/session_01TYiVFjVWzBxdAnrJFZJv66
Copy link
Copy Markdown
Member Author

fmontes commented Apr 18, 2026

Fixes from the triage above are now pushed directly to fix-websockets-v3 as commit f57a33c. The original branch reference (claude/review-pr-comments-4tBxM) is no longer needed — all 5 accepted review items are in the PR.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants