MarzbanSDK
Real-time

WebSocket Logs

Stream live logs from the Marzban Xray core and individual nodes over WebSocket via sdk.logs — connecting, the LogOptions reference, cleanup, and browser support.

sdk.logs provides live log streaming over WebSocket. It supports both the core Xray process and individual node logs, with automatic reconnection, token refresh, and duplicate-line suppression after a drop.

How it works

Each connect* call:

  1. Ensures the SDK has a valid token (re-authenticates if needed).
  2. Opens a WebSocket connection to the Marzban backend — the promise resolves only once the socket is genuinely open.
  3. Returns a stream handle: callable to close the stream, plus an explicit .close() and a live .state (see Stream state).

If the connection drops after opening, the stream reconnects on its own (exponential backoff, bounded by a time budget) and reports it through onReconnect/onOpen — see Reconnect & Stream Lifecycle. Active streams are tracked internally — sdk.destroy() closes all of them at once.

Connect to core logs

const stream = await sdk.logs.connectByCore({
  interval: 1, // polling interval in seconds (default: 1)
  onMessage: (data) => {
    console.log('[Core log]', data)
  },
  onError: (error) => {
    console.error('WebSocket error:', error.code, error.message)
  },
})

// Later, close the stream — the handle is callable, or use .close()
stream()

Connect to node logs

const stream = await sdk.logs.connectByNode(nodeId, {
  interval: 1,
  onMessage: (data) => {
    console.log(`[Node ${nodeId} log]`, data)
  },
  onError: (error) => {
    console.error('Node log error:', error.code, error.message)
  },
})

stream.close()

Multiple concurrent streams

const [core, node1, node2] = await Promise.all([
  sdk.logs.connectByCore({ onMessage: d => process.stdout.write(d) }),
  sdk.logs.connectByNode(1, { onMessage: d => process.stdout.write(d) }),
  sdk.logs.connectByNode(2, { onMessage: d => process.stdout.write(d) }),
])

// Close all streams at once
await sdk.destroy()

LogOptions

OptionTypeDefaultDescription
onMessage(data: ) => voidrequiredCalled on each incoming log line (already deduplicated per replay)
onError(error: ) => void—Called once, when the stream ends for good — a typed error, never a raw transport event
onOpen() => void—Called every time the stream reaches open — the first connect and each successful reconnect
onReconnect(info: ) => void—Called when a dropped stream successfully reopens
onClose(info: ) => void—Called once, when the stream ends for good — only if it ever reached open
intervalnumber1Polling interval in seconds. Must be 0–10 (fractional values allowed); 0 disables batching and streams each line as it arrives
replay'dedup'How to handle log lines the panel re-delivers after a reconnect — see Replay & Deduplication
reconnectenabledPer-call override of the reconnect policy

is { attempt: number; downtimeMs: number } — attempt is the 1-based reconnect attempt that succeeded (carries over across a flapping connection); downtimeMs is the time since the most recent drop.

is { code?: number; byCaller: boolean } — code is the WebSocket close code when one is known (absent for a caller-initiated close); byCaller is true when you closed the stream yourself, false when it died on its own.

interval/replay/reconnect are validated before a socket is opened — an invalid value throws a synchronously instead of round-tripping to the panel, which would otherwise reject an out-of-range interval as a generic 403 (see Error handling).

A throwing callback is caught and logged rather than propagated — it can't crash your process or stop later events from arriving.

onClose never fires for a first connect that fails: that's reported through the connect*() rejection alone. A self-inflicted end (the budget runs out, the panel refuses a fresh token) reports onError then onClose({ byCaller: false }); closing the stream yourself reports onClose({ byCaller: true }) with no onError. See Reconnect & Stream Lifecycle for the full callback sequence across a drop-and-recover cycle.

Cleanup

Always close streams when you no longer need them to avoid resource leaks:

// Close a single stream — the handle is callable, or use .close()
const stream = await sdk.logs.connectByCore({ onMessage: handler })
// ...
stream()

// Close all active streams + release other SDK resources
await sdk.destroy()

Custom agent & self-signed certificates

sdk.logs reuses the httpsAgent from your SDK config (see Self-signed certificates & custom CA) — nothing extra to configure:

import { readFileSync } from 'node:fs'
import https from 'node:https'
import { createMarzbanSDK } from 'marzban-sdk'

const sdk = await createMarzbanSDK({
  baseUrl: 'https://panel.example.com',
  username: 'admin',
  password: 'secret',
  httpsAgent: new https.Agent({ ca: readFileSync('ca.pem') }),
})

// Trusts the same CA as the REST calls above.
await sdk.logs.connectByCore({ onMessage: data => console.log(data) })

This is Node-only: neither the browser's native WebSocket nor Node's own global WebSocket (21+) can be given a custom agent, so a configured httpsAgent forces the connection through the ws-package-backed client instead — see "Browser support" below. In the browser the agent is ignored (with a warning), the same as the HTTP client.

Browser support

The WebSocket client auto-detects the runtime:

  • Modern environments (browser, Deno, Bun, Node.js 21+) — uses the native WebSocket global.
  • Older Node.js (19–20) — falls back to the ws package (a regular dependency — installed automatically, no extra setup needed).

No configuration required for that detection — it's fully transparent, unless you set httpsAgent, which forces the ws-backed client outside the browser (see above) regardless of what's natively available.

The access token also travels differently depending on which client is used: the ws-backed client sends it as an Authorization: Bearer header, so it never appears in the URL. The native WebSocket constructor has no headers option at all — a platform limit, not a gap in this SDK — so on that client (every browser, and Node.js 21+ without a configured httpsAgent) the token stays in the URL's token query parameter.

React / frontend example

import { useEffect } from 'react'
import type { MarzbanSDK } from 'marzban-sdk'

function NodeLogViewer({ sdk, nodeId }: { sdk: MarzbanSDK; nodeId: number }) {
  useEffect(() => {
    let close: (() => void) | undefined

    sdk.logs
      .connectByNode(nodeId, {
        onMessage: (data) => console.log(data),
        onError: (error) => console.error('Stream error', error.code, error.message),
      })
      .then(stream => { close = stream.close })

    return () => { close?.() }
  }, [sdk, nodeId])

  return <pre>watching node {nodeId} logs...</pre>
}

See also

On this page