Index of every page on this site: llms.txt

View Active Connections

Track active connections and the versions users run as a deployment rolls out.

Connection tracking only works with sse or ws update strategies. It does not support polling or external adapters (Pusher/Ably).

With connectionTracking: true, each tab keeps its own transport, routes, and statistics subscription. Shared push ownership applies only when connection tracking is off.

Setup

Enable connection tracking in your Nuxt config using connectionTracking:

nuxt.config.ts
export default defineNuxtConfig({
  skewProtection: {
    connectionTracking: true,
    routeTracking: true, // optional: track which routes users are viewing
    ipTracking: true, // optional: track user IP addresses
  }
})

Limitations

  • Single instance only: Stats are per-server process. With horizontal scaling, each instance only sees its own connections.
  • SSE/WS only: Does not work with polling strategy or external adapters (Pusher/Ably).
  • Bot traffic excluded: When you install @nuxtjs/robots, the module automatically excludes bot/crawler traffic from connection counts. See Bot Traffic Filtering.

IP Tracking

IP tracking requires explicit opt-in via ipTracking: true due to privacy considerations. When enabled, the module extracts IP addresses from request headers in this order:

  1. cf-connecting-ip (Cloudflare)
  2. x-forwarded-for (proxies/load balancers)
  3. x-real-ip (nginx)
The module only stores IPs in memory and exposes them via the stats API. Ensure your authorization hook properly restricts access to stats data.

Authorization

The server only sends stats to connections that pass authorization. Implement the skew:authorize-stats hook in a server plugin.

server/plugins/skew-auth.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('skew:authorize-stats', async ({ event, authorize }) => {
    // Works with nuxt-auth-utils
    const session = await getUserSession(event)
    if (session?.user?.role === 'admin') {
      authorize()
    }
  })
})

Cloudflare with nuxt-auth-utils. For WebSocket connections (e.g., with cloudflare-durable preset), the event object contains only { headers: Headers }. Unseal the session cookie manually:

server/plugins/skew-auth.ts
import { unsealSession } from 'h3'

const ADMIN_EMAILS = ['admin@example.com']

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('skew:authorize-stats', async ({ event, authorize }) => {
    // Try getUserSession first (works for SSE/POST)
    const session = await getUserSession(event).catch(() => {
      // WebSocket events lack the H3 context required by getUserSession.
      return null
    })
    if (session?.user?.email && ADMIN_EMAILS.includes(session.user.email)) {
      authorize()
      return
    }

    // For WebSocket, manually unseal the session cookie
    if (event?.headers instanceof Headers) {
      const cookieHeader = event.headers.get('cookie') || ''
      const match = cookieHeader.match(/nuxt-session=([^;]+)/)
      if (match?.[1]) {
        const config = useRuntimeConfig()
        const password = config.session?.password || process.env.NUXT_SESSION_PASSWORD || ''
        if (password) {
          const unsealed = await unsealSession(null as any, { password }, decodeURIComponent(match[1])).catch(() => {
            // Invalid or expired cookies represent an unauthenticated connection.
            return null
          })
          // Note: unsealSession returns { data: { user } }, not { user } directly
          const email = unsealed?.data?.user?.email
          if (email && ADMIN_EMAILS.includes(email)) {
            authorize()
          }
        }
      }
    }
  })
})
The key difference: getUserSession() returns { user: { email } } while unsealSession() returns { data: { user: { email } } }.

Using the Composable

The useActiveConnections() composable provides reactive access to connection statistics:

<script setup lang="ts">
import { useActiveConnections } from '#imports'

const { total, versions, authorized } = useActiveConnections()
</script>

<template>
  <div v-if="authorized">
    <p>Active users: {{ total }}</p>
    <ul>
      <li v-for="(count, version) in versions" :key="version">
        {{ version.slice(0, 8) }}: {{ count }} users
      </li>
    </ul>
  </div>
  <div v-else>
    Not authorized for stats
  </div>
</template>

Return values:

PropertyTypeDescription
totalComputedRef<number>Total active connections
versionsComputedRef<Record<string, number>>Map of buildId → connection count
routesComputedRef<Record<string, number>>Map of route → connection count (requires routeTracking)
connectionsComputedRef<ConnectionInfo[]>Individual connections with id, version, route, and ip (requires ipTracking for IP)
yourIdComputedRef<string | undefined>Your connection ID to identify yourself in the list
authorizedComputedRef<boolean | null>Whether the connection is authorized for stats (null while pending)

Example: Admin Dashboard

<script setup lang="ts">
const { total, versions, authorized } = useActiveConnections()
const currentBuildId = useRuntimeConfig().app.buildId

const stats = computed(() => {
  const current = versions.value[currentBuildId] || 0
  const outdated = total.value - current
  return {
    total: total.value,
    current,
    outdated,
    percentUpToDate: total.value ? Math.round((current / total.value) * 100) : 100
  }
})
</script>

<template>
  <div v-if="authorized" class="grid grid-cols-4 gap-4">
    <div class="stat">
      <span class="label">Total Users</span>
      <span class="value">{{ stats.total }}</span>
    </div>
    <div class="stat">
      <span class="label">On Latest</span>
      <span class="value text-green">{{ stats.current }}</span>
    </div>
    <div class="stat">
      <span class="label">On Old Version</span>
      <span class="value text-orange">{{ stats.outdated }}</span>
    </div>
    <div class="stat">
      <span class="label">Rollout Progress</span>
      <span class="value">{{ stats.percentUpToDate }}%</span>
    </div>
  </div>
  <div v-else>
    Not authorized for live stats
  </div>
</template>

Custom Behavior with Hooks

Use hooks to handle connection messages and lifecycle events directly.

Client-Side: skew:message

Listen to all messages from the SSE/WebSocket connection:

plugins/custom-stats.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hooks.hook('skew:message', (message) => {
    if (message.type === 'stats') {
      // Custom handling
      console.log('Connections:', message.total)
      console.log('Versions:', message.versions)
    }
  })
})

Server-Side: Connection Lifecycle

Build custom tracking logic with Nitro hooks:

server/plugins/custom-tracking.ts
import { defineNitroPlugin } from 'nitropack/runtime'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('skew:connection:open', ({ id, version, route, ip, send }) => {
    console.log(`Client ${id} connected on ${version} from ${ip}`)

    // Send custom welcome message
    send({ type: 'welcome', serverTime: Date.now() })
  })

  nitroApp.hooks.hook('skew:connection:close', ({ id }) => {
    console.log(`Client ${id} disconnected`)
  })
})

See Nuxt Hooks and Nitro Hooks for full documentation.

Was this page helpful?