// register vue composition api globally import type { RouteLocationNormalized, Router } from 'vue-router' import { Capacitor } from '@capacitor/core' import { setupLayouts } from 'virtual:generated-layouts' import { createApp } from 'vue' import { createRouter, createWebHistory } from 'vue-router' import { routes } from 'vue-router/auto-routes' import { isGuestRoutePath } from '~/constants/guestRoutes' import { prefetchSsoEnforcement } from '~/modules/sso-enforcement' import { getLocalConfig } from '~/services/console' import { installDeepLinkHandler } from '~/services/deepLinks' import { isDocumentNavigationPending, replaceDocument } from '~/services/documentNavigation' import { getNativeExternalPurchaseRedirect, isNativeAppStoreContext, isNativeExternalPurchaseRestrictedPath } from '~/services/nativeCompliance' import { posthogLoader } from '~/services/posthog' import { captureConsoleAttributionFromLocation } from '~/services/signupAttribution' import { getErrorMessage, isComponentResolutionErrorMessage, isKnownCrawlerNoiseErrorMessage, isStaleAssetErrorMessage } from '~/services/staleAssetErrors' import { installConsoleImageFallback } from '~/services/storage' import { getAllowedConfirmationHosts, resolveConfirmationUrl } from '~/utils/safeRedirect' import App from './App.vue' import { defaultApiHost, getRemoteConfig } from './services/console' import { resolveConsoleApiBase } from './services/consoleAuth' import '@fontsource-variable/inter' // your custom styles here import './styles/style.css' // Handle chunk load errors (stale chunks after deployment) // When a new version is deployed, old chunk URLs return 404/HTML instead of JS const CHUNK_RELOAD_TIMESTAMP_KEY = 'capgo_chunk_reload_timestamp' const CHUNK_RELOAD_TOAST_KEY = 'capgo_chunk_reload_toast' const CHUNK_RELOAD_COOLDOWN_MS = 30_000 function getChunkReloadTimestamp(): number | null { try { const storedValue = sessionStorage.getItem(CHUNK_RELOAD_TIMESTAMP_KEY) if (!storedValue) return null const timestamp = Number.parseInt(storedValue, 10) return Number.isFinite(timestamp) ? timestamp : null } catch { return null } } function setChunkReloadTimestamp(): void { try { sessionStorage.setItem(CHUNK_RELOAD_TIMESTAMP_KEY, String(Date.now())) } catch { // Ignore storage access failures and still let the reload happen. } } function hasChunkReloadToastPending(): boolean { try { return sessionStorage.getItem(CHUNK_RELOAD_TOAST_KEY) === 'true' } catch { return false } } function setChunkReloadToastPending(): void { try { sessionStorage.setItem(CHUNK_RELOAD_TOAST_KEY, 'true') } catch { // Ignore storage access failures and still let the reload happen. } } function clearChunkReloadToastPending(): void { try { sessionStorage.removeItem(CHUNK_RELOAD_TOAST_KEY) } catch { // Ignore storage access failures during cleanup. } } // `targetPath` is the route the user was navigating to when the chunk failed. // A bare reload restores the URL the browser is still on (the page the user is // leaving), so it silently throws the navigation away. When we know the target // we navigate there instead, so the user lands where they asked to go. function handleChunkError(message: string, targetPath?: string) { if (isDocumentNavigationPending()) return const previousReload = getChunkReloadTimestamp() if (previousReload && Date.now() - previousReload < CHUNK_RELOAD_COOLDOWN_MS) { console.warn('Chunk load error detected again after a recent reload, skipping automatic reload.', message) return } console.warn('Chunk load error detected, reloading page...', message) setChunkReloadTimestamp() setChunkReloadToastPending() if (targetPath) window.location.assign(targetPath) else window.location.reload() } window.addEventListener('error', (event) => { if (isStaleAssetErrorMessage(event.message)) { event.preventDefault() event.stopImmediatePropagation() handleChunkError(event.message) return } if (isKnownCrawlerNoiseErrorMessage(event.message)) { event.preventDefault() event.stopImmediatePropagation() } }, true) // Also handle unhandled promise rejections for dynamic imports window.addEventListener('unhandledrejection', (event) => { const message = getErrorMessage(event.reason) ?? String(event.reason) if (isStaleAssetErrorMessage(message)) { event.preventDefault() event.stopImmediatePropagation() handleChunkError(message) return } if (isKnownCrawlerNoiseErrorMessage(message)) { event.preventDefault() event.stopImmediatePropagation() } }) window.addEventListener('vite:preloadError', (event) => { const preloadEvent = event as Event & { payload?: unknown, detail?: unknown } const message = getErrorMessage(preloadEvent.payload) ?? getErrorMessage(preloadEvent.detail) ?? 'Vite preload error' if (!isStaleAssetErrorMessage(message)) return // Deliberately do NOT call event.preventDefault(): Vite's preload helper only // rethrows the underlying import failure when the default is not prevented. // Preventing it makes `__vitePreload` resolve with `undefined`, so a lazy route // resolves to a falsy component and vue-router throws "Couldn't resolve // component" instead of the real chunk error. Letting it rethrow keeps the // rejection matching STALE_ASSET_ERROR_PATTERNS, so it still gets the reload // (below) and the PostHog suppression this handler was written to provide. handleChunkError(message) }) function redirectAppPath(suffix: string) { return (to: RouteLocationNormalized) => ({ path: `/app/${encodeURIComponent(String((to.params as { app: string }).app))}${suffix}`, query: to.query, hash: to.hash, }) } getRemoteConfig() const app = createApp(App) installConsoleImageFallback() // The updater only does work inside the native shell; keep it out of the web entry chunk. async function notifyNativeAppReady() { const { CapacitorUpdater } = await import('@capgo/capacitor-updater') await CapacitorUpdater.notifyAppReady() } if (Capacitor.isNativePlatform()) notifyNativeAppReady().catch(error => console.error('Cannot notify the updater that the app is ready', error)) console.log(`Capgo Version : "${import.meta.env.VITE_APP_VERSION}"`) // setup up pages with layouts const newRoutes = routes.map((route) => { if (isGuestRoutePath(route.path)) { route.meta ??= {} route.meta.layout = 'naked' } else { route.meta ??= {} route.meta.middleware = 'auth' } return route }) const router = createRouter({ scrollBehavior(to, from, savedPosition) { if (savedPosition) return savedPosition if (to.hash) return { el: to.hash, top: 0 } if (from && to.path === from.path && (to.hash || '') === (from.hash || '')) return false return { top: 0 } }, routes: [ { path: '/', redirect: '/login' }, // Canonical apps list route { path: '/app', redirect: '/apps' }, { path: '/settings/plans', redirect: '/settings/organization/plans' }, { path: '/settings/usage', redirect: '/settings/organization/usage' }, { path: '/.well-known/change-password', redirect: '/settings/account/change-password' }, { path: '/settings/change-password', redirect: '/settings/account/change-password' }, { path: '/settings/changepassword', redirect: '/settings/account/change-password' }, { path: '/settings/notifications', redirect: '/settings/account/notifications' }, { path: '/dashboard/settings/organization/plans', redirect: '/settings/organization/plans' }, { path: '/dashboard/settings/organization/usage', redirect: '/settings/organization/usage' }, { path: '/p/:package', redirect: to => `/app/${(to.params as { package: string }).package}` }, { path: '/p/:package/logs', redirect: (to) => { const { tab: _, ...query } = to.query return { path: `/app/${encodeURIComponent(String((to.params as { package: string }).package))}/observe/logs`, query, hash: to.hash, } }, }, { path: '/dashboard/apikeys', redirect: '/apikeys' }, { path: '/dashboard/settings/account', redirect: '/settings/account' }, { path: '/dashboard/settings/change-password', redirect: '/settings/account/change-password' }, { path: '/dashboard/settings/notifications', redirect: '/settings/notifications' }, { path: '/dashboard/settings/organization/general', redirect: '/settings/organization/' }, { path: '/dashboard/settings/organization/members', redirect: '/settings/organization/members' }, { path: '/dashboard/settings/organization/plans', redirect: '/settings/organization/plans' }, { path: '/dashboard/settings/organization/usage', redirect: '/settings/organization/usage' }, { path: '/app/p/:package', redirect: to => `/app/${(to.params as { package: string }).package}` }, { path: '/app/p/:package/bundles', redirect: to => `/app/${(to.params as { package: string }).package}` }, { path: '/app/p/:package/channels', redirect: to => `/app/${(to.params as { package: string }).package}` }, { path: '/app/p/:package/devices', redirect: to => `/app/${(to.params as { package: string }).package}` }, { path: '/app/p/:package/logs', redirect: (to) => { const { tab: _, ...query } = to.query return { path: `/app/${encodeURIComponent(String((to.params as { package: string }).package))}/observe/logs`, query, hash: to.hash, } }, }, { path: '/app/package/:package', redirect: to => ({ path: `/app/${encodeURIComponent(String((to.params as { package: string }).package))}`, query: to.query, hash: to.hash, }), }, { path: '/app/package/:package/settings', redirect: to => ({ path: `/app/${encodeURIComponent(String((to.params as { package: string }).package))}/settings`, query: to.query, hash: to.hash, }), }, // Removed mobile modules / plugins pages { path: '/app/modules', redirect: '/apps' }, { path: '/app/modules_test', redirect: '/apps' }, { path: '/app/plugins', redirect: '/apps' }, // Legacy app tab URLs after settings/observe revamp { path: '/app/:app/info', redirect: redirectAppPath('/settings') }, { path: '/app/:app/access', redirect: redirectAppPath('/settings/access') }, { path: '/app/:app/logs/insights', redirect: redirectAppPath('/observe/errors') }, { path: '/app/:app/logs', redirect: redirectAppPath('/observe/logs') }, { path: '/app/:app/compatibility', redirect: redirectAppPath('/observe/compatibility') }, { path: '/app/:app/observe', redirect: redirectAppPath('') }, // Legacy overview sub-tabs and Observe tabs after the overview/observe regrouping { path: '/app/:app/live', redirect: redirectAppPath('') }, { path: '/app/:app/installs', redirect: redirectAppPath('') }, { path: '/app/:app/active-bundle', redirect: redirectAppPath('') }, { path: '/app/:app/native', redirect: redirectAppPath('/observe/native') }, { path: '/app/:app/observe/releases', redirect: redirectAppPath('') }, { path: '/app/:app/observe/updater', redirect: redirectAppPath('/observe/errors') }, { path: '/app/:app/observe/plugins', redirect: to => ({ ...redirectAppPath('/observe/compatibility')(to), query: { ...to.query, view: 'plugins' } }) }, ...setupLayouts(newRoutes), ], history: createWebHistory(import.meta.env.BASE_URL), }) // Recover from lazy-route load failures that slip past the vite:preloadError // handler (e.g. a navigation that races the reload). vue-router surfaces these // as "Couldn't resolve component" once a stale chunk fails to import, so treat // them as chunk errors and trigger the same reload instead of leaving the user // on a dead route. router.onError((error, to) => { const message = getErrorMessage(error) ?? String(error) if (isStaleAssetErrorMessage(message) || isComponentResolutionErrorMessage(message)) handleChunkError(message, to?.fullPath) }) // vue-router only fetches lazy route chunks after every beforeEach guard resolved, and the auth // guard waits on the API. Start the chunk downloads and the SSO enforcement request now so they // run in parallel with it; the router's own import() later reuses the same module request. router.beforeEach((to) => { prefetchSsoEnforcement(to.path) for (const record of to.matched) { for (const component of Object.values(record.components ?? {})) { if (typeof component === 'function' && !('displayName' in component) && !('props' in component)) void (component as () => Promise)()?.catch?.(() => {}) } } }) router.beforeEach((to, from, next) => { if (to.path === '/confirm-signup' || to.path === '/confirm-signup/') { try { const confirmationUrl = resolveConfirmationUrl(to.query, { allowedHosts: getAllowedConfirmationHosts(), allowLocalDev: import.meta.env.DEV, apiHost: resolveConsoleApiBase(defaultApiHost), }) if (confirmationUrl) { replaceDocument(confirmationUrl) return next(false) } } catch { // Let the confirmation page display a navigation error. } } if (isNativeAppStoreContext() && isNativeExternalPurchaseRestrictedPath(to.path)) { return next(getNativeExternalPurchaseRedirect(to.path)) } if (to.path.startsWith('/app/') && to.query.tab) { const tab = to.query.tab as string const newPath = to.path.endsWith('/') ? `${to.path}${tab}` : `${to.path}/${tab}` const { tab: _, ...newQuery } = to.query return next({ path: newPath, query: newQuery, hash: to.hash }) } next() }) const config = getLocalConfig() posthogLoader(config.apiHost) captureConsoleAttributionFromLocation() // install all modules under `modules/` type UserModule = (ctx: { app: typeof app, router: Router }) => void | Promise // Modules may return a promise (i18n loads the user's locale) that must settle before mount. // allSettled: a failing module must never keep the app from mounting. const moduleSetup = Promise.allSettled(Object.values(import.meta.glob<{ install: UserModule }>('./modules/*.ts', { eager: true })) .map(i => i.install?.({ app, router }))) app.use(router) void installDeepLinkHandler(router) void Promise.all([router.isReady(), moduleSetup]).then(async () => { if (isDocumentNavigationPending()) return app.mount('#app') // Wait for vue-sonner component to be mounted setTimeout(async () => { if (isDocumentNavigationPending()) return const key = hasChunkReloadToastPending() console.log('Checking for chunk reload toast...', key) // Show toast if we just reloaded due to chunk error if (key) { clearChunkReloadToastPending() const { toast } = await import('vue-sonner') toast.info('App updated! Page was refreshed to load the latest version.') } }, 500) })