fix(js): never let Reverb/Echo init abort Alpine component registration

The command-palette opened itself full-screen and undismissable right after
first login on production (bare-IP) installs. Root cause: app.js constructed
`window.Echo = new Echo({...})` at module top level. On auth-layout pages the
runtime reverb config (window.__clusev) is absent and the prod bundle bakes no
VITE_REVERB_* fallback (by design — the endpoint is resolved at runtime), so
key/wsHost were undefined and the constructor threw. That throw aborted app.js
BEFORE the alpine:init block registered cmdk / modalTrigger / dualChart. app.js
does not re-run across wire:navigate, so the dashboard inherited the broken
state: Alpine could not resolve x-data="cmdk(...)", stripped x-cloak but could
not evaluate x-show="open", and the palette div fell back to its default flex
display — a dimmed, undismissable overlay. Dev (Vite) hid the bug because it
bakes VITE_REVERB_* from the dev .env, so Echo never threw.

Fix: construct Echo only when a usable runtime config is present, wrap it in
try/catch so a websocket/config failure can never take down the UI, and re-run
the guarded init on livewire:navigated so realtime still connects on a
dashboard reached via SPA navigation from an auth page.

Verified on the real prod build (rebuilt VM image): palette stays closed after
first-login -> dashboard, opens on Ctrl+K and closes on ESC, Echo reaches
connected state, zero console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-19 17:19:31 +02:00
parent b1fb0e766a
commit 1e6fdb457e
1 changed files with 45 additions and 18 deletions

View File

@ -4,25 +4,52 @@ import './webauthn'; // exposes window.clusevWebauthn (register/login ceremonies
window.Pusher = Pusher;
// Reverb (Pusher protocol). The endpoint is injected at runtime by the layout
// Reverb (Pusher protocol). The endpoint is injected at RUNTIME by the app layout
// (window.__clusev.reverb), derived from the effective panel domain — so changing the
// domain from the dashboard needs no JS rebuild. Falls back to Vite env for HMR/dev.
const rc = (typeof window !== 'undefined' && window.__clusev && window.__clusev.reverb) || {};
const reverbScheme = rc.scheme ?? import.meta.env.VITE_REVERB_SCHEME ?? 'https';
const reverbPort = Number(rc.port ?? import.meta.env.VITE_REVERB_PORT ?? (reverbScheme === 'https' ? 443 : 80));
// Channels are PRIVATE: Echo POSTs to /broadcasting/auth (web group → PanelScheme + session)
// before subscribing, so we must send the CSRF token the layout exposes as <meta name="csrf-token">.
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
window.Echo = new Echo({
broadcaster: 'reverb',
key: rc.key ?? import.meta.env.VITE_REVERB_APP_KEY,
wsHost: rc.host ?? import.meta.env.VITE_REVERB_HOST,
wsPort: reverbPort,
wssPort: reverbPort,
forceTLS: reverbScheme === 'https',
enabledTransports: ['ws', 'wss'],
auth: { headers: { 'X-CSRF-TOKEN': csrfToken } },
});
// domain from the dashboard needs no JS rebuild. The prod build deliberately bakes NO
// VITE_REVERB_* env (it would freeze the build-time domain); those env reads are only a
// dev/HMR convenience.
//
// CRITICAL: this MUST NOT throw at module top level. Auth pages (login / forced password
// change) use a layout that injects no __clusev, and the prod bundle has no VITE_REVERB_*
// fallback, so the config is legitimately absent there. A thrown `new Echo({key: undefined})`
// would abort this whole module BEFORE the alpine:init block below registers cmdk /
// modalTrigger / dualChart — leaving the command-palette's `x-data="cmdk(...)"` unresolved.
// Alpine then strips x-cloak but can't evaluate `x-show="open"`, so the palette renders as a
// full-screen, undismissable overlay. So: construct only when a real config is present, guard
// it, and (re)try on navigation since the config first appears on app-layout pages.
function ensureEcho() {
if (window.Echo) return; // singleton — construct once
const rc = (typeof window !== 'undefined' && window.__clusev && window.__clusev.reverb) || {};
const key = rc.key ?? import.meta.env.VITE_REVERB_APP_KEY;
const host = rc.host ?? import.meta.env.VITE_REVERB_HOST;
if (! key || ! host) return; // no realtime config (e.g. auth pages) — skip, never throw
const scheme = rc.scheme ?? import.meta.env.VITE_REVERB_SCHEME ?? 'https';
const port = Number(rc.port ?? import.meta.env.VITE_REVERB_PORT ?? (scheme === 'https' ? 443 : 80));
// Channels are PRIVATE: Echo POSTs to /broadcasting/auth (web group → PanelScheme + session)
// before subscribing, so we must send the CSRF token the layout exposes as <meta name="csrf-token">.
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
try {
window.Echo = new Echo({
broadcaster: 'reverb',
key,
wsHost: host,
wsPort: port,
wssPort: port,
forceTLS: scheme === 'https',
enabledTransports: ['ws', 'wss'],
auth: { headers: { 'X-CSRF-TOKEN': csrfToken } },
});
} catch (e) {
// A realtime/websocket misconfig must never take down the rest of the UI.
console.error('[clusev] realtime (Echo/Reverb) init failed — continuing without live updates', e);
}
}
ensureEcho();
// The reverb config first appears when an app-layout page mounts; on a SPA navigation INTO it
// (e.g. forced password change → dashboard) this module does not re-run, so try again then.
document.addEventListener('livewire:navigated', ensureEcho);
// ── Modal-open detection ────────────────────────────────────────────────
// wire-elements/modal emits NO "opened" event; the only reliable, package-version-