Skip to content

Real-time Updates ​

Overview ​

  1. Two independent buses: Real-time functionality uses two separate mechanisms. Laravel Echo + Pusher delivers server-push events over WebSockets (asset changes, notifications, subscription updates). The mitt event bus delivers in-process browser events between components that cannot communicate via props/emits (downloads, folder reloads, collage updates).
  2. Lazy Echo initialization: plugins/echo.client.ts initializes the Echo instance on first use, not at app boot. Auth pages (login, forgot-password, etc.) receive a safe no-op mock instead of a real connection, preventing unnecessary WebSocket connections on unauthenticated routes.
  3. Bearer token auth: Private Pusher channels require authentication. Echo POSTs to the Laravel /broadcasting/auth endpoint with the auth_token cookie value as a Bearer token. The backend returns a channel auth signature.
  4. TanStack Query as the update target: When a Pusher event arrives, the handler calls queryClient.invalidateQueries with the affected key — not manual state mutation. TanStack re-fetches automatically if the query is active.
  5. Mitt for intra-app events: The mittBus composable exposes the global mitt emitter. Twenty-three named event constants in constants/events.ts define the inter-component communication vocabulary.
  6. Lifecycle discipline: Echo channel subscriptions are set up in onMounted and torn down in onBeforeUnmount (or onUnmounted). Violating this causes memory leaks and duplicate event handlers across navigations.

Architecture ​

The Echo plugin creates one Echo<'pusher'> instance per browser session, stored in module scope. It is provided to the Nuxt app as $echo and accessed from composables via useNuxtApp().$echo. The plugin also registers an app:unmounted hook that calls destroyEcho() to disconnect on HMR reloads in development. The mitt plugin is simpler — it creates a single mitt() emitter and provides it as $mittBus. The useMittBus() composable wraps the access so components never reference useNuxtApp() directly. Both buses are client-only (.client.ts suffix), meaning they are never initialized during SSR.

File Structure ​

TypeScript Plugin Files ​

  • app/plugins/echo.client.ts — initializes Laravel Echo with Pusher broadcaster; provides $echo to the Nuxt app; exports destroyEcho() for HMR cleanup
  • app/plugins/mitt.client.ts — creates a mitt emitter and provides it as $mittBus

TypeScript Composable Files ​

  • app/composables/core/common/useMittBus.ts — thin wrapper that returns the $mittBus emitter from useNuxtApp(); auto-imported
  • app/composables/core/components/useDamNotification.ts — consumes $echo to subscribe to notification badge and subscription channels; the primary example of real-world Echo usage

TypeScript Constant Files ​

  • app/constants/events.ts — 23 named string constants for mitt events (e.g. EVENT_DOWNLOAD_FILE, EVENT_ASSET_RENAMED)

echo.client.ts Plugin ​

File: app/plugins/echo.client.ts

Features ​

  • Module-scoped singleton: echoInstance is created once and reused across all components
  • Auth page guard: returns a no-op mock on routes named index, forgot-password, reset-password, social-login, generate-password
  • Auth token guard: returns a no-op mock if the auth_token cookie is absent
  • HMR cleanup: destroyEcho() exported for use in the app:unmounted hook
  • SSR fallback: provides a no-op mock during server rendering

Configuration ​

typescript
// nuxt.config.ts runtime config
runtimeConfig: {
  public: {
    pusherKey: process.env.NUXT_PUBLIC_PUSHER_KEY,
    pusherCluster: process.env.NUXT_PUBLIC_PUSHER_CLUSTER,
    pusherAuthEndpoint: process.env.NUXT_PUBLIC_PUSHER_AUTH_ENDPOINT,
  }
}

Echo Config Shape ​

typescript
{
  broadcaster: 'pusher',
  key: config.public.pusherKey,
  cluster: config.public.pusherCluster,
  encrypted: true,
  authEndpoint: config.public.pusherAuthEndpoint,
  auth: {
    headers: {
      Authorization: `Bearer ${authToken}`,
    },
  },
}

Exported Functions ​

typescript
// Disconnect and nullify the Echo instance (used in HMR hook)
export const destroyEcho = (): void

Usage ​

typescript
const { $echo } = useNuxtApp()

onMounted(() => {
  $echo.private(`asset.${assetId}`)
    .listen('.asset.updated', (data) => {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ASSET, assetId] })
    })
    .listen('.comment.created', onCommentCreated)
})

onUnmounted(() => {
  $echo.leave(`asset.${assetId}`)
})

mitt.client.ts Plugin ​

File: app/plugins/mitt.client.ts

Features ​

  • Provides a single global mitt() emitter as $mittBus
  • Accepts any string or symbol as event key
  • Zero configuration; no external service dependency

useMittBus Composable ​

File: app/composables/core/common/useMittBus.ts

typescript
export const useMittBus = (): Emitter<Events> | undefined => {
  const nuxtApp = useNuxtApp()
  return (nuxtApp.$mittBus as Emitter<Events>) || undefined
}

Usage ​

typescript
// Publisher
const mittBus = useMittBus()
mittBus?.emit(EVENT_ASSET_RENAMED, { id: assetId, name: newName })

// Subscriber (cleanup is your responsibility)
const mittBus = useMittBus()

onMounted(() => {
  mittBus?.on(EVENT_ASSET_RENAMED, handleAssetRenamed)
})

onUnmounted(() => {
  mittBus?.off(EVENT_ASSET_RENAMED, handleAssetRenamed)
})

Channel Reference ​

Echo Channels (Pusher) ​

ChannelEventsHandler location
user.{id}.projectBatchNotification.ProjectUserNotificationBadgesEventuseDamNotification
user.{id}.getUserSubscriptionChannel.GetUserSubscriptionEventuseDamNotification
asset.{id}.asset.updated, .comment.created, .version.createdAsset detail page components
workspace.{id}asset.created, asset.updated, asset.deleted, folder.created, folder.updated, folder.deleted, member.added, member.removed, notification.createdGlobal DAM pages
portal.{id}portal.updated, access.requestedPortal detail page

Mitt Events (constants/events.ts) ​

ConstantEvent stringPurpose
EVENT_UPDATE_COLLAGE_ASSETS'updateCollageAssets'Refresh collage asset list
EVENT_DISPATCH_ANALYTICS'dispatch-analytics'Fire a deferred analytics event
EVENT_SORT_COMPLETE_SHARE'sort-complete-share'Share list sort finished
EVENT_SORT_COMPLETE_EMBED'sort-complete-embed'Embed list sort finished
EVENT_HANDLE_VERSION_UPLOAD'handle-version-upload'Trigger version upload handler
EVENT_DOWNLOAD_ALL_FOLDER'download-all-folder'Download entire folder
EVENT_DOWNLOAD_FILE_COLLAGE'download-file-collage'Download collage file
EVENT_OPEN_CREATE_COLLAGE'openCreateCollage'Open create-collage dialog
EVENT_COLLAGE_RENAMED'collage-renamed'Collage name changed
EVENT_COLLAGE_DELETED'collage-deleted'Collage removed
EVENT_ASSET_RENAMED'asset-renamed'Asset name changed
EVENT_ASSETS_DELETED'assets-deleted'One or more assets removed
EVENT_FOLDER_RENAMED'folder-renamed'Folder name changed
EVENT_FOLDERS_DELETED'folders-deleted'One or more folders removed
EVENT_OPEN_ADD_FOLDER_DIALOG'open-add-folder-dailog'Open add folder dialog
EVENT_CREATING_FOLDER'creating-folder'Folder creation in progress
EVENT_RELOAD_FOLDER_DIALOG'reload-folder-dialog'Reload folder picker
EVENT_ADD_FOLDER'addFolder'Folder added to tree
EVENT_UPDATE_CATEGORY'update-category'Category/folder metadata updated
EVENT_RELOAD_FOLDERS'reload-folders'Refresh folder list
EVENT_DOWNLOAD_QUICK_VIEW'download-quick-view'Trigger quick-view download
EVENT_DOWNLOAD_FILE'download-file'Trigger single file download
EVENT_DOWNLOAD_COMPLETE_QUICK_VIEW'download-complete-quick-view'Quick-view download finished

Workflows ​

Echo Channel Subscription (Notification Badges) ​

1. DamNotification component mounts
   useDamNotification() called
   onMounted fires
   ↓
2. getBadgesCount() called
   GET endpoint → badge count loaded into badgesCount.value
   ↓
3. Echo subscriptions created
   echo.private('user.{user.id}.projectBatchNotification')
     .listen('.ProjectUserNotificationBadgesEvent', handler)

   echo.private('user.{user.id}.getUserSubscriptionChannel')
     .listen('.GetUserSubscriptionEvent', handler)
   ↓
4. Pusher sends .ProjectUserNotificationBadgesEvent
   Payload: { data: { dam_badges_count: 3 } }
   Handler updates badgesCount.value
   Bell icon badge updates in real time
   ↓
5. Pusher sends .GetUserSubscriptionEvent
   Payload: { data: { plan_assign: true } }
   Handler: fetchUser() → viewWorkspace() → updateSubscriptionEventSent()
   TanStack Query subscription cache invalidated → re-fetches plan data
   ↓
6. Component unmounts (onBeforeUnmount)
   echo.leave('user.{user.id}.projectBatchNotification')
   echo.leave('user.{user.id}.getUserSubscriptionChannel')

Mitt Event — Asset Rename ​

1. User renames asset in AssetDetail component
   API call succeeds
   ↓
2. Publisher emits event
   const mittBus = useMittBus()
   mittBus?.emit(EVENT_ASSET_RENAMED, { id: assetId, name: newName })
   ↓
3. Subscriber in DAM list / folder view receives event
   onMounted: mittBus?.on(EVENT_ASSET_RENAMED, handleAssetRenamed)
   ↓
4. Handler updates local state or invalidates TanStack Query cache
   Asset name reflects new value without a page reload
   ↓
5. Component unmounts
   onUnmounted: mittBus?.off(EVENT_ASSET_RENAMED, handleAssetRenamed)

Component Integration ​

Subscribing to a Private Pusher Channel ​

$echo is accessed from useNuxtApp() and cast to a typed interface inline — not destructured directly. The canonical example is from app/composables/core/components/useDamNotification.ts:

typescript
const nuxtApp = useNuxtApp()
const { user } = useAuth()

interface EchoChannel {
  listen: (event: string, callback: (e: EchoEvent) => void) => EchoChannel
}
const echo = nuxtApp.$echo as {
  private: (channel: string) => EchoChannel
  leave: (channel: string) => void
}

onMounted(async () => {
  if (!user.value?.id) return

  echo
    .private(`user.${user.value.id}.projectBatchNotification`)
    .listen('.ProjectUserNotificationBadgesEvent', (e: EchoEvent) => {
      const { dam_badges_count } = e.data
      badgesCount.value = damSystemNotificationAllowed.value ? dam_badges_count : 0
    })

  echo
    .private(`user.${user.value.id}.getUserSubscriptionChannel`)
    .listen('.GetUserSubscriptionEvent', async (e: EchoEvent) => {
      if (e.data.plan_assign) {
        await fetchUser()
        const data = await viewWorkspace()
        const wsData = data.data as { workspace_unique_id?: string | number } | undefined
        await updateSubscriptionEventSent({
          workspace_id: wsData?.workspace_unique_id,
          event_status: true,
        })
      }
    })
})

onBeforeUnmount(() => {
  if (user.value?.id) {
    echo.leave(`user.${user.value.id}.projectBatchNotification`)
    echo.leave(`user.${user.value.id}.getUserSubscriptionChannel`)
  }
})

Key details from the real implementation:

  • $echo is not destructured — it is accessed as nuxtApp.$echo and cast with a local EchoChannel interface
  • The cleanup hook is onBeforeUnmount, not onUnmounted
  • The user ID guard (if (!user.value?.id) return) runs inside onMounted before subscribing

Emitting and Receiving a Mitt Event ​

typescript
// Publisher — after an action completes, emit a bus event
const mittBus = useMittBus()

mittBus?.emit(EVENT_RELOAD_FOLDERS, undefined)

// Subscriber — register in onMounted, clean up in onUnmounted
const mittBus = useMittBus()

function handleReloadFolders() {
  // reload folder list
}

onMounted(() => mittBus?.on(EVENT_RELOAD_FOLDERS, handleReloadFolders))
onUnmounted(() => mittBus?.off(EVENT_RELOAD_FOLDERS, handleReloadFolders))

The real codebase also accesses $mittBus directly via useNuxtApp() in some composables without going through useMittBus():

typescript
const nuxtApp = useNuxtApp()
const mittBus = nuxtApp.$mittBus as {
  on: (e: string, h: (...a: unknown[]) => void) => void
  off: (e: string, h: (...a: unknown[]) => void) => void
} | undefined

Prefer useMittBus() in new code — it is the auto-imported wrapper that handles the cast.

useDamNotification Composable ​

File: app/composables/core/components/useDamNotification.ts

This is the primary consumer of $echo and serves as the canonical example of Echo channel subscription. It manages the notification bell in the main DAM layout.

Echo Channels Subscribed ​

typescript
// Notification badge count updates
echo.private(`user.${user.value.id}.projectBatchNotification`)
  .listen('.ProjectUserNotificationBadgesEvent', (e: EchoEvent) => {
    const { dam_badges_count } = e.data
    badgesCount.value = damSystemNotificationAllowed.value ? dam_badges_count : 0
  })

// Subscription plan assignment after payment
echo.private(`user.${user.value.id}.getUserSubscriptionChannel`)
  .listen('.GetUserSubscriptionEvent', async (e: EchoEvent) => {
    if (e.data.plan_assign) {
      await fetchUser()
      const data = await viewWorkspace()
      await updateSubscriptionEventSent({
        workspace_id: wsData?.workspace_unique_id,
        event_status: true,
      })
    }
  })

Cleanup in onBeforeUnmount ​

typescript
onBeforeUnmount(() => {
  if (user.value?.id) {
    echo.leave(`user.${user.value.id}.projectBatchNotification`)
    echo.leave(`user.${user.value.id}.getUserSubscriptionChannel`)
  }
})

Return Value (selected fields) ​

typescript
{
  badgesCount: Ref<number>               // unread badge count for bell icon
  displayBatchCount: ComputedRef<{ show: boolean; count_: string }>
  systemNotifications: Ref<Notification[]>
  announcements: Ref<AnnouncementNotificationItem[]>
  unreadNotifications: Ref<number>
  unreadAnnouncements: Ref<number>
  isMenuOpen: Ref<boolean>
  initialLoading: Ref<boolean>
  loadMoreLoading: Ref<boolean>
  damSystemNotificationAllowed: ComputedRef<boolean>  // gated by subscription feature
  changeTab(tabIndex: number): void
  openModal(announcement: AnnouncementNotificationItem): void
  readUnreadNotification(id: number, redirect?: boolean): Promise<void>
  readAllNotifications(): Promise<void>
  readUnreadAnnouncement(id: number): Promise<void>
  readAllAnnouncements(): Promise<void>
  loadMoreNotifications(): Promise<void>
  loadMoreAnnouncements(): Promise<void>
  onScroll(event: Event): void
}

Subscription Feature Gate ​

damSystemNotificationAllowed is derived from user.subscription_features.system_notifications.enable. When false, the notification bell shows only the Announcements tab and incoming ProjectUserNotificationBadgesEvent events set badgesCount to 0.

EchoChannel Interface ​

Components and composables that subscribe to Echo channels expect the following interface from $echo.private(channelName):

typescript
interface EchoChannel {
  listen(event: string, callback: (e: EchoEvent) => void): EchoChannel
}

The .listen() call returns the channel itself, enabling method chaining for multiple event subscriptions on the same channel:

typescript
echo.private(`asset.${assetId}`)
  .listen('.asset.updated', onAssetUpdated)
  .listen('.comment.created', onCommentCreated)
  .listen('.version.created', onVersionCreated)

Note: Pusher event names in .listen() calls are prefixed with . (dot) when they are broadcast with the ShouldBroadcast interface in Laravel and the channel name does not use a wildcard.

Auth Page Handling ​

On routes named index, forgot-password, reset-password, social-login, or generate-password, Echo initialization returns a no-op mock rather than a real WebSocket connection:

typescript
// Safe mock — all methods are present but do nothing
{
  private: () => ({ listen: () => ({}) }),
  leave: () => {},
  channel: () => ({ listen: () => ({}) }),
}

This prevents unnecessary WebSocket connections and Pusher auth calls on unauthenticated pages. The real Echo instance is created only after the user navigates to an authenticated workspace route.

Environment Variables ​

VariablePurpose
NUXT_PUBLIC_PUSHER_KEYPusher app key (public, safe to expose)
NUXT_PUBLIC_PUSHER_CLUSTERPusher cluster region (e.g. mt1)
NUXT_PUBLIC_PUSHER_AUTH_ENDPOINTLaravel broadcasting auth endpoint URL

The auth endpoint URL is typically the Laravel backend /broadcasting/auth route. The Echo client sends a POST with the channel name and the user's Bearer token; Laravel validates and returns a channel signature.

Presence Channels ​

Presence channels ($echo.join(channelName)) are not currently implemented. They are reserved for future use cases such as showing which workspace members are actively viewing the same asset detail page.