Appearance
Real-time Updates ​
Overview ​
- 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
mittevent bus delivers in-process browser events between components that cannot communicate via props/emits (downloads, folder reloads, collage updates). - Lazy Echo initialization:
plugins/echo.client.tsinitializes 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. - Bearer token auth: Private Pusher channels require authentication. Echo POSTs to the Laravel
/broadcasting/authendpoint with theauth_tokencookie value as a Bearer token. The backend returns a channel auth signature. - TanStack Query as the update target: When a Pusher event arrives, the handler calls
queryClient.invalidateQuerieswith the affected key — not manual state mutation. TanStack re-fetches automatically if the query is active. - Mitt for intra-app events: The
mittBuscomposable exposes the globalmittemitter. Twenty-three named event constants inconstants/events.tsdefine the inter-component communication vocabulary. - Lifecycle discipline: Echo channel subscriptions are set up in
onMountedand torn down inonBeforeUnmount(oronUnmounted). 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$echoto the Nuxt app; exportsdestroyEcho()for HMR cleanupapp/plugins/mitt.client.ts— creates amittemitter and provides it as$mittBus
TypeScript Composable Files ​
app/composables/core/common/useMittBus.ts— thin wrapper that returns the$mittBusemitter fromuseNuxtApp(); auto-importedapp/composables/core/components/useDamNotification.ts— consumes$echoto 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:
echoInstanceis 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_tokencookie is absent - HMR cleanup:
destroyEcho()exported for use in theapp:unmountedhook - 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 = (): voidUsage ​
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) ​
| Channel | Events | Handler location |
|---|---|---|
user.{id}.projectBatchNotification | .ProjectUserNotificationBadgesEvent | useDamNotification |
user.{id}.getUserSubscriptionChannel | .GetUserSubscriptionEvent | useDamNotification |
asset.{id} | .asset.updated, .comment.created, .version.created | Asset detail page components |
workspace.{id} | asset.created, asset.updated, asset.deleted, folder.created, folder.updated, folder.deleted, member.added, member.removed, notification.created | Global DAM pages |
portal.{id} | portal.updated, access.requested | Portal detail page |
Mitt Events (constants/events.ts) ​
| Constant | Event string | Purpose |
|---|---|---|
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:
$echois not destructured — it is accessed asnuxtApp.$echoand cast with a localEchoChannelinterface- The cleanup hook is
onBeforeUnmount, notonUnmounted - The user ID guard (
if (!user.value?.id) return) runs insideonMountedbefore 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
} | undefinedPrefer 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 ​
| Variable | Purpose |
|---|---|
NUXT_PUBLIC_PUSHER_KEY | Pusher app key (public, safe to expose) |
NUXT_PUBLIC_PUSHER_CLUSTER | Pusher cluster region (e.g. mt1) |
NUXT_PUBLIC_PUSHER_AUTH_ENDPOINT | Laravel 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.