nuxt-easy-websocket

Easy WebSocket integration module for nuxt
6,850 6
Install
npm install nuxt-easy-websocket
Latest Version:4.1.3
Older releases:3
License:MIT
Last Updated:Oct 23, 2026
Links: GitHub  ·  npm
Maintainer: bombastickj

Nuxt EasyWebSocket

npm version npm downloads License Nuxt

A powerful Nuxt module providing seamless WebSocket integration with type-safe communication between client and server.

Features

  • 🔄 Auto-reconnection with configurable attempts and delay
  • 🌐 Multiple WebSocket connections (default internal + external)
  • 🔍 Type-safe messaging between client and server
  • 🧩 File-based routing for WebSocket events
  • 🔌 Connection lifecycle hooks (open, close)
  • 🛡️ Origin-checked upgrades with a built-in same-origin default and an extensible upgrade hook for auth
  • 🛠️ Simple API for sending and receiving messages
  • 📦 Binary event support with named, file-routed binary messages
  • 🧵 Raw WebSocket passthrough for third-party protocols and unframed binary messages
  • 📝 TypeScript support with full type inference

Installation

npx nuxi@latest module add nuxt-easy-websocket

Setup

Add nuxt-easy-websocket to the modules section of your nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['nuxt-easy-websocket'],
})

Configuration

Configure the module with these options in your nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['nuxt-easy-websocket'],
  easyWebSocket: {
    // Directory for client-side WebSocket event handlers (default: 'socket')
    clientSrcDir: 'socket',
    
    // Directory for server-side WebSocket event handlers (default: 'socket')
    serverSrcDir: 'socket',
    
    // Delimiter for route paths (default: '/')
    delimiter: '/',
    
    // WebSocket connection options
    ws: {
      // Maximum number of reconnection attempts (default: 10)
      maxReconnectAttempts: 10,
      
      // Delay between reconnection attempts in milliseconds (default: 5000)
      reconnectDelay: 5000,
      
      // Whether to automatically reconnect when connection closes (default: true)
      reconnectOnClose: true,

      // Reject WebSocket upgrades whose Origin header doesn't match the server's host (default: true)
      originCheck: true,

      heartbeat: {
        active: true,
        timeoutMs: 30_000, // Server heartbeat interval
        // Optional: defaults to twice timeoutMs; must be greater than timeoutMs
        clientTimeoutMs: 60_000,
      },
    },
    
    // External WebSocket connections (optional)
    externalSockets: {
      'example-socket': {
        url: 'wss://example.com/socket',
        // Override default connection options for this socket (optional)
        ws: {
          maxReconnectAttempts: 5,
          reconnectDelay: 3000,
        }
      }
    }
  }
})

Heartbeats and Silent Connection Loss

Heartbeat monitoring is enabled by default for the internal socket. The server sends the literal text message _heartbeat every heartbeat.timeoutMs milliseconds (default: 30_000), and the client replies with the same message. The server terminates peers that do not respond by its next check.

The client also watches for missing heartbeats. Its deadline starts when the connection opens and resets with each _heartbeat message; ordinary application messages do not reset it. If the deadline expires, the client immediately reports disconnected, records Heartbeat timeout with close code 4000, and requests closure of the old socket. It does not wait for the browser's close event before applying reconnectOnClose, reconnectDelay, and maxReconnectAttempts.

heartbeat.clientTimeoutMs is optional and defaults to twice the resolved timeoutMs (60 seconds with the default interval). An explicit deadline must be finite and strictly greater than timeoutMs; the interval must be finite and positive when monitoring is active. Invalid configuration throws an error identifying the socket. A longer client deadline tolerates more delayed heartbeats but takes longer to detect a silent connection loss. Browser suspension or timer throttling can delay detection beyond the configured deadline.

export default defineNuxtConfig({
  easyWebSocket: {
    ws: {
      heartbeat: {
        active: true,
        timeoutMs: 30_000,
        clientTimeoutMs: 90_000,
      },
    },
  },
})

External sockets have heartbeat monitoring disabled by default, because third-party servers may not implement this protocol. To enable it, configure heartbeat.active: true for that socket. The external server must send _heartbeat at the configured interval and accept the client's _heartbeat reply. WebSocket protocol Ping/Pong frames are separate from these text messages and do not reset this watchdog.

externalSockets: {
  'another-easy-ws-server': {
    url: 'wss://example.com/_ws',
    ws: {
      heartbeat: {
        active: true,
        timeoutMs: 15_000,
        clientTimeoutMs: 45_000,
      },
    },
  },
}

External sockets inherit timing options from ws.heartbeat unless overridden. If no explicit client deadline is inherited or provided, it is calculated from that socket's resolved interval. An inherited explicit deadline must still be greater than any external interval override. Connections to another EasyWebSocket server should explicitly enable heartbeats so they reply to that server's liveness checks. Set heartbeat.active: false to disable both client monitoring and automatic heartbeat replies; for the internal socket this also disables the server heartbeat checks.

disconnect() immediately reports disconnected and applies the automatic reconnect policy. Use disconnect(true) to stay disconnected and cancel a pending reconnect. Use connect() to resume, or forceReconnect() to replace the current connection immediately, without waiting for the old socket to close.

Heartbeat recovery detects silent connection loss; it does not replay application messages or guarantee their delivery. Sends made while waiting for a connection still wait for it to open.

Usage

Directory Structure

The module uses a file-based routing system similar to Nitro Routes:

📁 project/
├── 📁 app/
│   ├── 📁 socket/                  # Client-side WebSocket handlers
│   │   ├── 📄 _binary.ts           # Handle unframed binary messages from the default socket
│   │   ├── 📄 _text.ts             # Handle raw text messages from the default socket
│   │   ├── 📄 chat.ts              # Handle 'chat' events from server
│   │   └── 📄 notification.ts      # Handle 'notification' events from server
│   │
│   └── 📁 example-socket/          # External WebSocket handlers (folder name must match the socket name)
│       ├── 📄 _binary.ts           # Handle unframed binary messages from this external socket
│       ├── 📄 _text.ts             # Handle raw text messages from this external socket
│       └── 📄 pong.ts              # Handle 'pong' event from external socket
│
├── 📁 server/
│   ├── 📁 socket/                  # Server-side WebSocket handlers
│   │   ├── 📄 upgrade.ts           # Upgrade request validation (origin/auth checks)
│   │   ├── 📄 open.ts              # Connection opened event
│   │   ├── 📄 close.ts             # Connection closed event
│   │   └── 📁 api/                 # API endpoints for client-to-server communication
│   │       ├── 📄 _binary.ts       # Handle unframed binary messages from client
│   │       ├── 📄 _text.ts         # Handle raw text messages from client
│   │       ├── 📄 message.ts       # Handle 'message' events from client
│   │       └── 📁 user/            # API endpoints for client-to-server communication
│   │           └── 📄 login.ts     # Handle 'user/login' events from client
│   │
└── 📄 nuxt.config.ts

Important: For external WebSockets, the directory name must match the socket name defined in the configuration. For example, if you configured an external socket named example-socket, its event handlers should be placed in app/example-socket/.

Client-Side

Defining Client-Side Event Handlers

Create a file in your app/socket directory (or the configured clientSrcDir):

// app/socket/chat.ts
export default defineEasyWSEvent<{
  message: string
  user: string
  timestamp: number
}>(({ data }) => {
  console.log(`New message from ${data.user}: ${data.message}`)
  // Handle the chat message from the server
})
Using WebSocket in Components
<script setup>
// Access the WebSocket instance
const ws = useEasyWS()

// Connection status
const status = ws.connectionStatus
const isConnected = computed(() => status.value === 'connected')

// Send a message to the server
function sendMessage() {
  ws.send('api/message', {
    text: 'Hello world!',
    userId: 123
  })
}

// External WebSocket (if configured)
// Only available if you've defined external sockets in your config
const externalWs = useExternalWS('example-socket')
function sendExternalMessage() {
  externalWs.send('ping', { timestamp: Date.now() })
}
</script>

<template>
  <div>
    <p>Connection status: {{ status }}</p>
    <button @click="sendMessage" :disabled="!isConnected">
      Send Message
    </button>
    <button @click="ws.forceReconnect">
      Reconnect
    </button>
  </div>
</template>

Server-Side

Validating Upgrade Requests

WebSocket upgrades are not protected by the browser's same-origin policy, so by default the module rejects any upgrade whose Origin header doesn't match the server's Host header (set ws.originCheck: false to disable this). For additional checks — session/cookie auth, allow-listing origins, rate limiting by IP — add an upgrade.ts handler. It runs before open, so you can reject a connection before any socket is established.

// server/socket/upgrade.ts
export default defineEasyWSUpgrade(async (request) => {
  const origin = request.headers.get('origin')
  if (origin && !['https://myapp.com'].includes(origin)) {
    throw new Response(null, { status: 403, statusText: 'Forbidden' })
  }

  // Optionally attach data to the peer, available later via `peer.context`
  const user = await validateSession(request)
  if (!user) {
    throw new Response(null, { status: 401, statusText: 'Unauthorized' })
  }

  return { context: { user } }
})

The handler receives the raw Request object from the upgrade and can:

  • Reject the connection by throwing (or returning) a Response
  • Allow the connection by returning nothing
  • Attach data to the peer by returning { context }, readable later via peer.context
  • Add response headers by returning { headers }
Defining Connection Handlers
// server/socket/open.ts
export default defineEasyWSConnection(({ peer }) => {
  console.log(`New client connected: ${peer.peer.id}`)
  
  // Subscribe client to topics
  peer.subscribe('announcements')
})

// server/socket/close.ts
export default defineEasyWSConnection(({ peer }) => {
  console.log(`Client disconnected: ${peer.peer.id}`)
})
Defining Server-Side Event Handlers
// server/socket/api/message.ts
export default defineEasyWSEvent<{
  text: string
  userId: number
}>(async ({ data, peer }) => {
  console.log(`Received message from user ${data.userId}: ${data.text}`)
  
  // Send a response back to the client
  await peer.send('chat', {
    message: `Echo: ${data.text}`,
    user: 'Server',
    timestamp: Date.now()
  })
  
  // Send to all subscribed clients
  await peer.publish('announcements', {
    message: `User ${data.userId} sent a message`,
    timestamp: Date.now()
  })
})
Broadcasting to Connected Clients
// server/api/broadcast.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  
  // Broadcast to all connected clients
  for (const peer of EasyWSConnections.values()) {
    await peer.send('notification', {
      title: 'Broadcast',
      message: body.message,
      timestamp: Date.now()
    })
  }
  
  return { success: true }
})

Using External WebSockets

If you've configured external WebSocket connections, you can handle EasyWebSocket-style events from them:

File: app/example-socket/pong.ts

export default defineEasyWSEvent<{
  timestamp: number
}>(({ data }) => {
  const diffInMs = Date.now() - data.timestamp
  console.log(`Pong took: ${diffInMs}ms`)
})

For third-party WebSockets that do not use the EasyWebSocket message envelope or binary frame format, use sendRaw() to send exact text or binary data.

File: app/components/ExternalSocketExample.vue

<script setup lang="ts">
const externalWs = useExternalWS('example-socket')

await externalWs.sendRaw(new Uint8Array([1, 2, 3]))
await externalWs.sendRaw('SUBSCRIBE ticker:BTC')
</script>

Raw text messages and unframed binary messages received from an external socket are routed to reserved handlers inside the matching external socket directory: _text receives string payloads, and _binary receives Uint8Array payloads.

File: app/example-socket/_text.ts

export default defineEasyWSEvent<string>(({ data }) => {
  console.log('Received raw external text packet:', data)
})

File: app/example-socket/_binary.ts

export default defineEasyWSEvent<Uint8Array>(({ data }) => {
  console.log('Received raw external binary packet:', data)
})

Type Augmentation for External WebSockets

To get type safety with external WebSockets, augment the module types by creating a declaration file:

// project/types/external-ws.d.ts
declare module '#nuxt-easy-websocket/routes' {
  interface EasyWSExternalRoutes {
    'example-socket': {
      ping: { timestamp: number }
    }
  }
}

This provides type safety when using useExternalWS composable:

// Type-safe usage with the augmented types
const externalWs = useExternalWS('example-socket')
externalWs.send('ping', { timestamp: Date.now() }) // Fully typed!

Binary Messages and Raw WebSocket Data

nuxt-easy-websocket supports routed EasyWebSocket messages and raw WebSocket passthrough:

API Use case Routing behavior
send(name, data) with JSON-compatible data EasyWebSocket text events Sends the standard JSON envelope { name, data } and routes by event name.
send(name, data) with binary data EasyWebSocket binary events Adds the EasyWebSocket binary frame and routes by event name.
sendRaw(data) Third-party protocols or byte-exact messages Sends the data exactly as provided. No event name, JSON envelope, or magic header is added. Incoming raw text is routed to _text; incoming unframed binary is routed to _binary.

EasyWebSocket-routed binary messages use the same file-based event names as JSON messages. The binary frame stores the encoded event-name length as a 32-bit unsigned integer, so deeply nested routes and long custom delimiters are supported.

Use send() when both sides use nuxt-easy-websocket. Use sendRaw() when the other side expects a custom protocol, such as a third-party WebSocket server, or when you intentionally want to send a raw text or binary WebSocket message.

Sending Routed Binary Events

When send() receives an ArrayBuffer, ArrayBufferView such as Uint8Array, or Blob, the payload is wrapped in an EasyWebSocket binary frame. This keeps binary messages compatible with named events and file-based routing.

File: app/components/BinaryUpload.vue

<script setup lang="ts">
const ws = useEasyWS()

const bytes = new Uint8Array([1, 2, 3])
await ws.send('api/process-binary', bytes)
</script>

File: server/socket/api/process-binary.ts

export default defineEasyWSEvent<Uint8Array>(({ data, peer }) => {
  // Binary EasyWebSocket payloads are delivered as Uint8Array.
  console.log('Received routed binary event:', data)

  peer.send('binary-response', new Uint8Array([4, 5, 6]))
})

File: app/socket/binary-response.ts

export default defineEasyWSEvent<Uint8Array>(({ data }) => {
  console.log('Received routed binary response:', data)
})
Sending Raw WebSocket Data

Use sendRaw() when you do not want the EasyWebSocket frame or JSON envelope. This is useful for external sockets, custom text protocols, and custom binary protocols.

File: app/components/RawSocketMessage.vue

<script setup lang="ts">
const ws = useEasyWS()

await ws.sendRaw(new Uint8Array([1, 2, 3]))
await ws.sendRaw('raw text message')
</script>

On the server, EasyWSPeer also exposes raw send helpers.

File: server/socket/api/send-raw.ts

export default defineEasyWSEvent<undefined>(async ({ peer }) => {
  await peer.sendRaw(new Uint8Array([4, 5, 6]))
  await peer.sendRaw('raw text message')
})
Receiving Raw Text and Unframed Binary Data

Incoming raw text messages are routed to a reserved _text event. Incoming binary messages that do not contain the EasyWebSocket magic header are routed to a reserved _binary event.

File: server/socket/api/_text.ts

export default defineEasyWSEvent<string>(({ data, peer }) => {
  console.log('Received raw text from client:', data)

  peer.sendRaw(`echo: ${data}`)
})

File: server/socket/api/_binary.ts

export default defineEasyWSEvent<Uint8Array>(({ data, peer }) => {
  console.log('Received raw binary from client:', data)
})

File: app/socket/_text.ts

export default defineEasyWSEvent<string>(({ data }) => {
  console.log('Received raw text from server:', data)
})

File: app/socket/_binary.ts

export default defineEasyWSEvent<Uint8Array>(({ data }) => {
  console.log('Received raw binary from server:', data)
})

For external WebSockets, place the _text and _binary handlers in the external socket directory.

File: app/example-socket/_text.ts

export default defineEasyWSEvent<string>(({ data }) => {
  console.log('Received raw text from example-socket:', data)
})

File: app/example-socket/_binary.ts

export default defineEasyWSEvent<Uint8Array>(({ data }) => {
  console.log('Received raw binary from example-socket:', data)
})

How it works: EasyWebSocket binary messages use a lightweight frame: [0xE7][0x57][4 bytes: NameLength][Name][RawData]. The NameLength field is an unsigned 32-bit integer encoded in network byte order, so binary event names support deeply nested routes and long custom delimiters. If incoming binary data does not start with the EasyWebSocket magic bytes, it is treated as unframed binary and routed to _binary. Raw text messages that are not valid EasyWebSocket JSON envelopes are routed to _text. Text-based EasyWebSocket messages continue to use the standard JSON envelope: { name, data }. Raw text/binary classification depends on the original raw message value exposed by the WebSocket runtime through CrossWS; conversion helpers such as message.text() or message.uint8Array() should not be used to decide the original frame type.

TypeScript Support

The module provides full TypeScript support with type inference for your WebSocket events. The types are automatically generated based on your file structure.

Advanced Usage

Accessing Connection State

<script setup>
const ws = useEasyWS()

// Read-only state object
const state = ws.state

// Computed helpers
const isConnected = computed(() => ws.connectionStatus.value === 'connected')
const maxAttemptsReached = ws.maxReconnectAttemptsReached
</script>

<template>
  <div>
    <p>Status: {{ ws.connectionStatus }}</p>
    <p v-if="state.lastError">Last error: {{ state.lastError }}</p>
    <p v-if="state.reconnectCountdown">
      Reconnecting in {{ state.reconnectCountdown }}s...
    </p>
    <p v-if="maxAttemptsReached">
      Max reconnection attempts reached
    </p>
  </div>
</template>

Server-Side Topic Subscriptions

WebSocket clients can subscribe to topics for pub/sub functionality:

// server/socket/api/subscribe.ts
export default defineEasyWSEvent<{
  topic: string
}>(async ({ data, peer }) => {
  // Subscribe client to the requested topic
  await peer.subscribe(data.topic)
  
  // Confirm subscription
  await peer.send('notification', {
    title: 'Subscribed',
    message: `You are now subscribed to ${data.topic}`,
    timestamp: Date.now()
  })
})

Contribution

Local development
# Install dependencies
npm install

# Generate type stubs
npm run dev:prepare

# Develop with the playground
npm run dev

# Build the playground
npm run dev:build

# Run ESLint
npm run lint

# Run Vitest
npm run test
npm run test:watch

# Release new version
npm run release

License

MIT License

Version History

Version Released Vue License
4.1.3 MIT
4.1.2 MIT
4.1.1 MIT
4.1.0 MIT
3.5.1 MIT
Pre-releases (6)
Version Released Vue License
4.0.0-alpha.5 MIT
4.0.0-alpha.4 MIT
4.0.0-alpha.3 ^3.0 MIT
4.0.0-alpha.2 ^3.0 MIT
4.0.0-alpha.1 ^3.0 MIT