Skip to content

Packages

@surfjs/vue

Vue 3 composables for Surf — local commands, real-time state, channels, and Surf Live

@surfjs/vue

Vue 3 composables wrapping @surfjs/web. Register local command handlers, subscribe to Surf Live events, manage channels, and sync state — all with Vue reactivity.

Installation#

Bash
npm install @surfjs/vue

@surfjs/web is included as a dependency — no separate install needed.

Peer dependencies: vue ^3.3.0

SurfProvider#

Manages WebSocket connection and provides context to child components via Vue's provide/inject:

vue
<template>
<SurfProvider
url="wss://myapp.com/surf/ws"
endpoint="https://myapp.com"
auth="your-token"
:channels="['project-123']"
>
<App />
</SurfProvider>
</template>
 
<script setup lang="ts">
import { SurfProvider } from '@surfjs/vue'
</script>

Props

| Prop | Type | Description | |------|------|-------------| | url | string | WebSocket URL for Surf Live (required) | | endpoint | string? | Server URL for HTTP fallback. Also registers window.surf | | auth | string? | Auth token sent on connect | | channels | string[]? | Surf Live channels to subscribe to on connect |

Auto-Reconnect

The provider automatically reconnects with exponential backoff: 1s → 2s → 4s → 8s → max 30s.

Channel subscriptions are restored on reconnect.

window.surf Integration

When endpoint is provided, SurfProvider automatically initialises window.surf with a WebSocket-backed server executor. This means @surfjs/web functions like registerCommand route through the active WebSocket instead of HTTP fallback.

useSurf#

Access the Surf context (requires SurfProvider):

vue
<script setup lang="ts">
import { useSurf } from '@surfjs/vue'
 
const { execute, status, connected, sessionId } = useSurf()
 
async function search(query: string) {
const result = await execute('search', { query })
if (result.ok) {
console.log(result.result)
}
}
</script>
 
<template>
<div>
<span>Status: {{ status }}</span>
<button :disabled="!connected" @click="search('laptop')">
Search
</button>
</div>
</template>

Returns

| Property | Type | Description | |----------|------|-------------| | execute | (command: string, params?: Record<string, unknown>) => Promise<SurfResult> | Execute a command via WebSocket | | status | Ref<ConnectionStatus> | Current connection status | | connected | ComputedRef<boolean> | Whether the WebSocket is connected | | sessionId | Ref<string \| undefined> | Current session ID, if active | | subscribe | (event: string, cb: EventCallback) => () => void | Subscribe to Surf events | | subscribeChannel | (channelId: string) => void | Subscribe to a channel | | unsubscribeChannel | (channelId: string) => void | Unsubscribe from a channel | | channels | Ref<ReadonlySet<string>> | Currently subscribed channels |

Throws if used outside SurfProvider.

useSurfEvent#

Subscribe to Surf Live events. Automatically cleans up on unmount:

vue
<script setup lang="ts">
import { useSurfEvent } from '@surfjs/vue'
 
useSurfEvent('notification', (data) => {
const { message, level } = data as { message: string; level: string }
showToast(message, level)
})
 
useSurfEvent('timeline.updated', (data) => {
refreshTimeline()
})
</script>

Parameters

| Param | Type | Description | |-------|------|-------------| | event | string | Event name to listen for | | callback | (data: unknown) => void | Called when the event fires |

useSurfChannel#

Manage channel subscriptions dynamically:

vue
<script setup lang="ts">
import { useSurfChannel } from '@surfjs/vue'
 
const { subscribe, unsubscribe, channels } = useSurfChannel()
 
function joinProject(id: string) {
subscribe(`project-${id}`)
}
 
function leaveProject(id: string) {
unsubscribe(`project-${id}`)
}
</script>
 
<template>
<div>Active channels: {{ channels.size }}</div>
</template>

Returns

| Property | Type | Description | |----------|------|-------------| | subscribe | (channelId: string) => void | Subscribe to a channel | | unsubscribe | (channelId: string) => void | Unsubscribe from a channel | | channels | Ref<ReadonlySet<string>> | Currently subscribed channels |

useSurfState#

Reactive state synced from Surf Live events. Returns a Vue ref that auto-updates when the server sends surf:state or surf:patch events:

vue
<script setup lang="ts">
import { useSurfState } from '@surfjs/vue'
 
interface Metrics {
users: number
events: number
latency: number
}
 
const metrics = useSurfState<Metrics>('metrics', {
users: 0,
events: 0,
latency: 0,
})
</script>
 
<template>
<div class="dashboard">
<span>Online: {{ metrics.users }}</span>
<span>Events: {{ metrics.events }}</span>
<span>Latency: {{ metrics.latency }}ms</span>
</div>
</template>

How it works

  • surf:state — Full state replacement for the given key
  • surf:patch — Deep merge of partial updates into existing state

Both carry a version number. Out-of-order messages with stale versions are ignored automatically.

Parameters

| Param | Type | Description | |-------|------|-------------| | key | string | State key (matches against channel name in events) | | initialState | T | Initial value before any server events arrive |

Returns Ref<T>.

useSurfCommands#

Register local command handlers with window.surf. Handlers run in the browser — no server roundtrip for mode: 'local' commands:

vue
<script setup lang="ts">
import { useSurfCommands } from '@surfjs/vue'
import { useCanvasStore } from '@/stores/canvas'
 
const store = useCanvasStore()
 
useSurfCommands({
'theme.toggle': {
mode: 'local',
run: () => {
document.documentElement.classList.toggle('dark')
return { ok: true }
},
},
'canvas.addCircle': {
mode: 'local',
run: (params) => {
store.addCircle(params as { x: number; y: number; radius: number })
return { ok: true }
},
},
'doc.save': {
mode: 'sync',
run: (params) => {
store.saveLocal(params)
return { ok: true }
},
},
})
</script>

Handler Modes

| Mode | Behaviour | |------|-----------| | local | Runs only in the browser. No server call. | | sync | Runs locally AND posts to server in the background for persistence. |

Works with or without SurfProvider — registers directly with window.surf via @surfjs/web. Handlers are registered on mount and cleaned up on unmount.

SurfBadge#

A floating badge indicating the site is Surf-enabled. Helps AI vision models discover your commands:

vue
<template>
<SurfBadge
endpoint="https://myapp.com"
name="My App"
:commands="commands"
position="bottom-left"
theme="auto"
/>
</template>
 
<script setup lang="ts">
import { SurfBadge, type SurfBadgeCommand } from '@surfjs/vue'
 
const commands: SurfBadgeCommand[] = [
{ name: 'search', description: 'Search products' },
{ name: 'cart.add', description: 'Add item to cart' },
]
</script>

Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | endpoint | string | — | Server URL (required) | | name | string? | — | App name shown on hover | | description | string? | — | App description | | commands | SurfBadgeCommand[] | [] | Commands to advertise | | position | 'bottom-right' \| 'bottom-left' \| 'inline' | 'bottom-left' | Badge placement | | theme | 'dark' \| 'light' \| 'auto' | 'auto' | Colour scheme | | className | string? | — | Additional CSS class |

If no SurfProvider is wrapping the badge, it automatically registers window.surf with an HTTP-only server executor via the endpoint prop.

SSR Considerations#

Nuxt / SSR frameworks

All composables and components are safe for SSR — they guard against window and WebSocket access on the server:

  • SurfProvider — WebSocket connection and window.surf registration only run inside onMounted (client-side)
  • useSurfCommands — Calls registerCommand from @surfjs/web, which no-ops when window is unavailable
  • useSurfState — Returns the initialState on the server; hydrates reactively on the client
  • SurfBadge — Renders hidden until mounted (visibility: hidden until client-side)

No special configuration needed. Just use the composables as normal in your Nuxt pages or SSR Vue apps.

Lazy-loading with defineAsyncComponent

For apps that don't need Surf on every page:

vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
 
const SurfProvider = defineAsyncComponent(() =>
import('@surfjs/vue').then(m => m.SurfProvider)
)
</script>

TypeScript#

All exports are fully typed. Key types you can import:

TypeScript
import type {
// Context & connection
SurfContextValue,
ConnectionStatus, // 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
SurfResult,
EventCallback,
 
// Commands
SurfCommandConfig, // = CommandConfig from @surfjs/web
SurfCommandsMap, // Record<string, SurfCommandConfig>
 
// Badge
SurfBadgeProps,
SurfBadgeCommand,
} from '@surfjs/vue'

SurfResult

TypeScript
interface SurfResult {
ok: boolean
result?: unknown
error?: { code: string; message: string }
state?: Record<string, unknown>
}

SurfContextValue

TypeScript
interface SurfContextValue {
execute: (command: string, params?: Record<string, unknown>) => Promise<SurfResult>
status: Ref<ConnectionStatus>
connected: ComputedRef<boolean>
sessionId: Ref<string | undefined>
subscribe: (event: string, callback: EventCallback) => () => void
subscribeChannel: (channelId: string) => void
unsubscribeChannel: (channelId: string) => void
channels: Ref<ReadonlySet<string>>
}

Re-exports from @surfjs/web#

For convenience, @surfjs/vue re-exports the core @surfjs/web functions so you don't need a separate import:

TypeScript
import {
initSurf,
ensureSurf,
registerCommand,
unregisterCommand,
getSurf,
destroySurf,
} from '@surfjs/vue'

See @surfjs/web for details on these functions.