Documentation

    Documentation

    Documentation

    Ring Platform Logo

    Завантаження документації...

    Підготовка контенту платформи Ring

    Ring Platform Logo

    Завантаження документації...

    Підготовка контенту платформи Ring

    Ring Platform Logo

    Завантаження документації...

    Підготовка контенту платформи Ring

    1. /
    2. /Tunnel Protocol

    Updated Jul 7, 20267 min listen

    1. /
    2. /Tunnel Protocol

    Updated Jul 7, 20267 min listen

    1. /
    2. /Tunnel Protocol

    Updated Jul 7, 20267 min listen

    Concepts, value, and typical clone scenarios — less code.

    Welcome to Ring
    Quick Reference
    Getting Started
    Prerequisites
    Installation
    First Success Validation
    Next Steps
    Architecture
    Data Model
    Real Time
    Discovery Mutation Sync
    Security
    Backend Services
    k8s-postgres-fcm Mode
    Firebase Integration
    Features
    Authentication
    Email AI-CRM
    Entities
    Opportunities
    Notifications
    Push Notifications with FCM (Ring-Powered)
    Tunnel Protocol
    Wallet & Credit System
    Multi-Vendor Store
    Referral Codes (Refcodes)
    Affiliate & Referral Enablement
    Payments Overview
    PaymentConductor
    SubscriptionConductor
    VideoConductor
    News Module - Digital Newspaper Experience
    Member Blogs
    Public Profile Pages
    Username Reservation System
    Scientific Editor
    Locale System
    Security & Compliance
    NFT Marketplace
    Token Staking System
    Performance Optimization Patterns
    Mobile Experience
    Wallet
    Wallet Security Tips
    API
    Authentication
    Email AI-CRM API
    Entities
    Opportunities
    Messaging API
    Notifications API
    Wallet API
    Store API
    Admin API
    Customization
    Quick Start — Your First Ring Clone
    Customization Guide
    Token Economics Setup
    Payment Gateway Integration
    Reference Ring deployments
    Branding
    Themes
    Web3
    Token launch jurisdictions
    Deployment
    Self-hosted deployment
    Vercel
    Docker
    Monitoring & Analytics
    Performance Optimization
    Backup & Recovery
    Development
    Local Setup
    Code Structure
    Documentation components
    Community tooling
    Ring MCP Server
    Generative Images (ImageConductor)
    Autonomous Newsroom (Grok)
    OSS vs enterprise
    Whitelabel Navigation
    Best Practices
    Workflow
    Code Style
    Performance
    Testing
    Deployment
    Debugging
    Contributing
    MCP
    ring-image-create
    ring-video-create
    Examples
    Quick Start
    White Label
    Real World
    Integrations
    Ethereum wallets (Wagmi v3)

    Quick entry (CTOs · auditors · agents)

    Welcome — mission & audiences
    Quick Reference
    Getting started
    Architecture & Auth.js
    Backend modes & databases (DB_BACKEND_MODE)
    Self-hosted
    Ring MCP Tools
    Ring MCP Server
    Token economics
    Token launch jurisdictions
    Deploy (Docker · k8s)
    Security & compliance reads
    ringdom.org — LegioX homebase
    Source — MIT license (GitHub)

    Concepts, value, and typical clone scenarios — less code.

    Welcome to Ring
    Quick Reference
    Getting Started
    Prerequisites
    Installation
    First Success Validation
    Next Steps
    Architecture
    Data Model
    Real Time
    Discovery Mutation Sync
    Security
    Backend Services
    k8s-postgres-fcm Mode
    Firebase Integration
    Features
    Authentication
    Email AI-CRM
    Entities
    Opportunities
    Notifications
    Push Notifications with FCM (Ring-Powered)
    Tunnel Protocol
    Wallet & Credit System
    Multi-Vendor Store
    Referral Codes (Refcodes)
    Affiliate & Referral Enablement
    Payments Overview
    PaymentConductor
    SubscriptionConductor
    VideoConductor
    News Module - Digital Newspaper Experience
    Member Blogs
    Public Profile Pages
    Username Reservation System
    Scientific Editor
    Locale System
    Security & Compliance
    NFT Marketplace
    Token Staking System
    Performance Optimization Patterns
    Mobile Experience
    Wallet
    Wallet Security Tips
    API
    Authentication
    Email AI-CRM API
    Entities
    Opportunities
    Messaging API
    Notifications API
    Wallet API
    Store API
    Admin API
    Customization
    Quick Start — Your First Ring Clone
    Customization Guide
    Token Economics Setup
    Payment Gateway Integration
    Reference Ring deployments
    Branding
    Themes
    Web3
    Token launch jurisdictions
    Deployment
    Self-hosted deployment
    Vercel
    Docker
    Monitoring & Analytics
    Performance Optimization
    Backup & Recovery
    Development
    Local Setup
    Code Structure
    Documentation components
    Community tooling
    Ring MCP Server
    Generative Images (ImageConductor)
    Autonomous Newsroom (Grok)
    OSS vs enterprise
    Whitelabel Navigation
    Best Practices
    Workflow
    Code Style
    Performance
    Testing
    Deployment
    Debugging
    Contributing
    MCP
    ring-image-create
    ring-video-create
    Examples
    Quick Start
    White Label
    Real World
    Integrations
    Ethereum wallets (Wagmi v3)

    Quick entry (CTOs · auditors · agents)

    Welcome — mission & audiences
    Quick Reference
    Getting started
    Architecture & Auth.js
    Backend modes & databases (DB_BACKEND_MODE)
    Self-hosted
    Ring MCP Tools
    Ring MCP Server
    Token economics
    Token launch jurisdictions
    Deploy (Docker · k8s)
    Security & compliance reads
    ringdom.org — LegioX homebase
    Source — MIT license (GitHub)

    Concepts, value, and typical clone scenarios — less code.

    Welcome to Ring
    Quick Reference
    Getting Started
    Prerequisites
    Installation
    First Success Validation
    Next Steps
    Architecture
    Data Model
    Real Time
    Discovery Mutation Sync
    Security
    Backend Services
    k8s-postgres-fcm Mode
    Firebase Integration
    Features
    Authentication
    Email AI-CRM
    Entities
    Opportunities
    Notifications
    Push Notifications with FCM (Ring-Powered)
    Tunnel Protocol
    Wallet & Credit System
    Multi-Vendor Store
    Referral Codes (Refcodes)
    Affiliate & Referral Enablement
    Payments Overview
    PaymentConductor
    SubscriptionConductor
    VideoConductor
    News Module - Digital Newspaper Experience
    Member Blogs
    Public Profile Pages
    Username Reservation System
    Scientific Editor
    Locale System
    Security & Compliance
    NFT Marketplace
    Token Staking System
    Performance Optimization Patterns
    Mobile Experience
    Wallet
    Wallet Security Tips
    API
    Authentication
    Email AI-CRM API
    Entities
    Opportunities
    Messaging API
    Notifications API
    Wallet API
    Store API
    Admin API
    Customization
    Quick Start — Your First Ring Clone
    Customization Guide
    Token Economics Setup
    Payment Gateway Integration
    Reference Ring deployments
    Branding
    Themes
    Web3
    Token launch jurisdictions
    Deployment
    Self-hosted deployment
    Vercel
    Docker
    Monitoring & Analytics
    Performance Optimization
    Backup & Recovery
    Development
    Local Setup
    Code Structure
    Documentation components
    Community tooling
    Ring MCP Server
    Generative Images (ImageConductor)
    Autonomous Newsroom (Grok)
    OSS vs enterprise
    Whitelabel Navigation
    Best Practices
    Workflow
    Code Style
    Performance
    Testing
    Deployment
    Debugging
    Contributing
    MCP
    ring-image-create
    ring-video-create
    Examples
    Quick Start
    White Label
    Real World
    Integrations
    Ethereum wallets (Wagmi v3)

    Quick entry (CTOs · auditors · agents)

    Welcome — mission & audiences
    Quick Reference
    Getting started
    Architecture & Auth.js
    Backend modes & databases (DB_BACKEND_MODE)
    Self-hosted
    Ring MCP Tools
    Ring MCP Server
    Token economics
    Token launch jurisdictions
    Deploy (Docker · k8s)
    Security & compliance reads
    ringdom.org — LegioX homebase
    Source — MIT license (GitHub)
    Docs
    Features
    Docs
    Features
    Docs
    Features

    Tunnel Protocol

    Filter this page with Founder / Developer in the docs sidebar. Founders learn what stays live without reload; developers get modules, hooks, and verified subscribe patterns.

    Ring Platform's realtime layer pushes per-user inbox updates (notifications, credit balance, account status) and topic channels (chat, opportunities, discovery) over a single shared connection. Production k8s runs a custom Node entrypoint (server.ts) so native WSS attaches at /api/tunnel/ws. Vercel uses serverless routes with SSE + long-polling only.

    Pair with Push notifications (FCM) when the tab is closed — lib/tunnel/disconnected-tunnel-context.ts keeps the UI usable while disconnected.

    LayerModuleRole
    Transport deduplib/tunnel/channel-subscription-registry.tsOne transport subscribe per channel; pending-promise guard
    App hookhooks/use-tunnel-channel.tsStable refs; effect deps [resolvedChannel, isConnected] only
    Providercomponents/providers/tunnel-provider.tsxShared connection + registry instance
    Connect timinglib/tunnel/tunnel-timing.tsProgressive connect; priorityRoutes / deferredRoutes via matchesRoutePrefix()
    Root listenerscomponents/providers/global-tunnel-listeners.tsxSingle mount point for global tunnel side-effects (AccountStatusTunnelListener, …)
    HTTP dedup (non-tunnel)hooks/use-vendor-status.ts, hooks/use-credit-balance.tsSingle-flight REST guards for sidebar Strict Mode / provider remounts
    Locale SSOTlib/pathname-without-locale.tslocaleFromPathname for root shell (next/navigation, not @/i18n/routing)
    Campaign SSOTlib/tunnel/SUBSCRIPTION-SSOT.mdDeprecated hooks removed, provider order, 2026-07-07 timing + queue parity

    Do not call useTunnel().subscribe() inside useEffect with subscribe in the dependency array. Use useTunnelChannel or useSync with subscribeRef pattern instead.

    A TunnelPublisher: Queued for user … (no live socket yet) line right after login is expected, not an error — it logs at console.debug in lib/tunnel/publisher.ts (not console.log). HTTP ingest (e.g. POST /api/analytics/device) persists first; tunnel connect follows a short auth-grace delay in components/providers/tunnel-provider.tsx. The hub queues the message and drains on connect — SSE via drainUserQueueForSse, native WSS via hub.drainUserQueue() on auth_ok (2026-07-07 parity). See lib/tunnel/SUBSCRIPTION-SSOT.md § "Tunnel timing + duplicate fetch consolidation".

    What founders get from Tunnel

    Your clone feels alive: unread badges update, chat messages appear, marketplace cards refresh after someone posts — without asking users to reload.

    Typical scenarios

    Notification inbox

    New opportunity matches, order updates, and admin alerts appear in the bell icon while the user stays on the page.

    Live chat

    Members see replies and typing indicators on conversation:* channels — critical for vendor and matcher workflows.

    Marketplace freshness

    When a listing is approved, open /opportunities tabs update via the opportunities channel.

    Architecture

    Subscribe path (SSOT)

    API routes

    RouteMethodPurpose
    /api/tunnel/sseGETSSE stream; registers user with TunnelHub
    /api/tunnel/wsWSNative WebSocket (k8s/self-hosted custom server only)
    /api/tunnel/tokenPOSTJWT for tunnel auth (SSE + WSS)
    /api/tunnel/pollGET/POSTLong-polling inbox for restrictive networks
    /api/tunnel/subscribePOSTChannel subscription (deduped client-side)
    /api/tunnel/unsubscribe

    Alpha limitations

    • Single-process hub — memory and k8s-postgres use InMemoryTunnelHub; correct for one replica. Horizontal scale requires backlog hub modes below.
    • Poll fan-out — poll clients receive subscribed channel messages via the poll inbox; SSE is preferred when available.

    Related

    Realtime transport

    TunnelHub broker flow, offline queue drain, and feature publish/subscribe matrix.

    Discovery mutation sync

    Topic channel publishes after opportunity and entity CRUD.

    Notifications API

    Inbox REST + notifications:inbox tunnel channel.

    Messaging API

    conversation:${id} channel patterns for live chat.

    Tunnel Protocol

    Filter this page with Founder / Developer in the docs sidebar. Founders learn what stays live without reload; developers get modules, hooks, and verified subscribe patterns.

    Ring Platform's realtime layer pushes per-user inbox updates (notifications, credit balance, account status) and topic channels (chat, opportunities, discovery) over a single shared connection. Production k8s runs a custom Node entrypoint (server.ts) so native WSS attaches at /api/tunnel/ws. Vercel uses serverless routes with SSE + long-polling only.

    Pair with Push notifications (FCM) when the tab is closed — lib/tunnel/disconnected-tunnel-context.ts keeps the UI usable while disconnected.

    LayerModuleRole
    Transport deduplib/tunnel/channel-subscription-registry.tsOne transport subscribe per channel; pending-promise guard
    App hookhooks/use-tunnel-channel.tsStable refs; effect deps [resolvedChannel, isConnected] only
    Providercomponents/providers/tunnel-provider.tsxShared connection + registry instance
    Connect timinglib/tunnel/tunnel-timing.tsProgressive connect; priorityRoutes / deferredRoutes via matchesRoutePrefix()
    Root listenerscomponents/providers/global-tunnel-listeners.tsxSingle mount point for global tunnel side-effects (AccountStatusTunnelListener, …)
    HTTP dedup (non-tunnel)hooks/use-vendor-status.ts, hooks/use-credit-balance.tsSingle-flight REST guards for sidebar Strict Mode / provider remounts
    Locale SSOTlib/pathname-without-locale.tslocaleFromPathname for root shell (next/navigation, not @/i18n/routing)
    Campaign SSOTlib/tunnel/SUBSCRIPTION-SSOT.mdDeprecated hooks removed, provider order, 2026-07-07 timing + queue parity

    Do not call useTunnel().subscribe() inside useEffect with subscribe in the dependency array. Use useTunnelChannel or useSync with subscribeRef pattern instead.

    A TunnelPublisher: Queued for user … (no live socket yet) line right after login is expected, not an error — it logs at console.debug in lib/tunnel/publisher.ts (not console.log). HTTP ingest (e.g. POST /api/analytics/device) persists first; tunnel connect follows a short auth-grace delay in components/providers/tunnel-provider.tsx. The hub queues the message and drains on connect — SSE via drainUserQueueForSse, native WSS via hub.drainUserQueue() on auth_ok (2026-07-07 parity). See lib/tunnel/SUBSCRIPTION-SSOT.md § "Tunnel timing + duplicate fetch consolidation".

    What founders get from Tunnel

    Your clone feels alive: unread badges update, chat messages appear, marketplace cards refresh after someone posts — without asking users to reload.

    Typical scenarios

    Notification inbox

    New opportunity matches, order updates, and admin alerts appear in the bell icon while the user stays on the page.

    Live chat

    Members see replies and typing indicators on conversation:* channels — critical for vendor and matcher workflows.

    Marketplace freshness

    When a listing is approved, open /opportunities tabs update via the opportunities channel.

    Architecture

    Subscribe path (SSOT)

    API routes

    RouteMethodPurpose
    /api/tunnel/sseGETSSE stream; registers user with TunnelHub
    /api/tunnel/wsWSNative WebSocket (k8s/self-hosted custom server only)
    /api/tunnel/tokenPOSTJWT for tunnel auth (SSE + WSS)
    /api/tunnel/pollGET/POSTLong-polling inbox for restrictive networks
    /api/tunnel/subscribePOSTChannel subscription (deduped client-side)
    /api/tunnel/unsubscribe

    Alpha limitations

    • Single-process hub — memory and k8s-postgres use InMemoryTunnelHub; correct for one replica. Horizontal scale requires backlog hub modes below.
    • Poll fan-out — poll clients receive subscribed channel messages via the poll inbox; SSE is preferred when available.

    Related

    Realtime transport

    TunnelHub broker flow, offline queue drain, and feature publish/subscribe matrix.

    Discovery mutation sync

    Topic channel publishes after opportunity and entity CRUD.

    Notifications API

    Inbox REST + notifications:inbox tunnel channel.

    Messaging API

    conversation:${id} channel patterns for live chat.

    Tunnel Protocol

    Filter this page with Founder / Developer in the docs sidebar. Founders learn what stays live without reload; developers get modules, hooks, and verified subscribe patterns.

    Ring Platform's realtime layer pushes per-user inbox updates (notifications, credit balance, account status) and topic channels (chat, opportunities, discovery) over a single shared connection. Production k8s runs a custom Node entrypoint (server.ts) so native WSS attaches at /api/tunnel/ws. Vercel uses serverless routes with SSE + long-polling only.

    Pair with Push notifications (FCM) when the tab is closed — lib/tunnel/disconnected-tunnel-context.ts keeps the UI usable while disconnected.

    LayerModuleRole
    Transport deduplib/tunnel/channel-subscription-registry.tsOne transport subscribe per channel; pending-promise guard
    App hookhooks/use-tunnel-channel.tsStable refs; effect deps [resolvedChannel, isConnected] only
    Providercomponents/providers/tunnel-provider.tsxShared connection + registry instance
    Connect timinglib/tunnel/tunnel-timing.tsProgressive connect; priorityRoutes / deferredRoutes via matchesRoutePrefix()
    Root listenerscomponents/providers/global-tunnel-listeners.tsxSingle mount point for global tunnel side-effects (AccountStatusTunnelListener, …)
    HTTP dedup (non-tunnel)hooks/use-vendor-status.ts, hooks/use-credit-balance.tsSingle-flight REST guards for sidebar Strict Mode / provider remounts
    Locale SSOTlib/pathname-without-locale.tslocaleFromPathname for root shell (next/navigation, not @/i18n/routing)
    Campaign SSOTlib/tunnel/SUBSCRIPTION-SSOT.mdDeprecated hooks removed, provider order, 2026-07-07 timing + queue parity

    Do not call useTunnel().subscribe() inside useEffect with subscribe in the dependency array. Use useTunnelChannel or useSync with subscribeRef pattern instead.

    A TunnelPublisher: Queued for user … (no live socket yet) line right after login is expected, not an error — it logs at console.debug in lib/tunnel/publisher.ts (not console.log). HTTP ingest (e.g. POST /api/analytics/device) persists first; tunnel connect follows a short auth-grace delay in components/providers/tunnel-provider.tsx. The hub queues the message and drains on connect — SSE via drainUserQueueForSse, native WSS via hub.drainUserQueue() on auth_ok (2026-07-07 parity). See lib/tunnel/SUBSCRIPTION-SSOT.md § "Tunnel timing + duplicate fetch consolidation".

    What founders get from Tunnel

    Your clone feels alive: unread badges update, chat messages appear, marketplace cards refresh after someone posts — without asking users to reload.

    Typical scenarios

    Notification inbox

    New opportunity matches, order updates, and admin alerts appear in the bell icon while the user stays on the page.

    Live chat

    Members see replies and typing indicators on conversation:* channels — critical for vendor and matcher workflows.

    Marketplace freshness

    When a listing is approved, open /opportunities tabs update via the opportunities channel.

    Architecture

    Subscribe path (SSOT)

    API routes

    RouteMethodPurpose
    /api/tunnel/sseGETSSE stream; registers user with TunnelHub
    /api/tunnel/wsWSNative WebSocket (k8s/self-hosted custom server only)
    /api/tunnel/tokenPOSTJWT for tunnel auth (SSE + WSS)
    /api/tunnel/pollGET/POSTLong-polling inbox for restrictive networks
    /api/tunnel/subscribePOSTChannel subscription (deduped client-side)
    /api/tunnel/unsubscribe

    Alpha limitations

    • Single-process hub — memory and k8s-postgres use InMemoryTunnelHub; correct for one replica. Horizontal scale requires backlog hub modes below.
    • Poll fan-out — poll clients receive subscribed channel messages via the poll inbox; SSE is preferred when available.

    Related

    Realtime transport

    TunnelHub broker flow, offline queue drain, and feature publish/subscribe matrix.

    Discovery mutation sync

    Topic channel publishes after opportunity and entity CRUD.

    Notifications API

    Inbox REST + notifications:inbox tunnel channel.

    Messaging API

    conversation:${id} channel patterns for live chat.

    Account enforcement

    Suspend/reactivate pushes on account:status redirect or refresh the session without a manual logout.

    Credit balance

    Wallet and subscription UI reflects chip-ins and spend limits in real time on credit:balance — one shared subscription via CreditBalanceProvider inside TunnelProvider.

    Operator expectations

    • Single replica today — TUNNEL_HUB_MODE=k8s-postgres uses in-process fan-out; scale-out requires backlog hub modes (widgets below).
    • FCM is the offline path — tunnel covers active tabs only.
    • Stale UI after deploy — check /api/tunnel/test and browser network for repeated subscribe calls (should be one per channel per session).
    POST
    Channel unsubscription
    /api/tunnel/publishPOSTAuthenticated publish (admin/tools)
    /api/tunnel/testGETEnvironment and provider diagnostics

    Server publishing

    Topic fan-out (chat, discovery, matcher):

    Per-user inbox (unread counts, credit balance):

    lib/discovery/sync-discovery.ts wraps publishToChannel for opportunity and entity CRUD — see Discovery mutation sync.

    Provider tree (AppClientShell)

    Tunnel consumers must be descendants of TunnelProvider. CreditBalanceProvider and GlobalTunnelListeners mount inside the provider so they share one registry (fix 2026-06-23):

    AccountStatusTunnelListener lives under GlobalTunnelListeners — do not delete it; add future global listeners there instead of mounting siblings directly in AppClientShell. Root shell uses next/navigation (usePathname, useRouter); locale resolution is localeFromPathname from lib/pathname-without-locale.ts (same contract as GoogleOneTap).

    Client subscriptions (SSOT)

    Wrap the app in TunnelProvider (AppClientShell). Prefer useTunnelChannel for feature subscriptions:

    Payload + state hook (credit balance pattern):

    Production uses CreditBalanceProvider (components/providers/credit-balance-provider.tsx) — one useCreditBalance() call, many context consumers.

    REST fetch + tunnel-driven refresh (notifications unread, entity lists):

    Production chat uses hooks/use-messaging.ts (useMessages, useTyping) — both delegate to useTunnelChannel on conversation:${id}.

    1. 1

      Confirm TunnelProvider wraps CreditBalanceProvider, GlobalTunnelListeners, and route children. No standalone useTunnel({ autoConnect: true }) parallel to the app provider.

    2. 2

      Pass stable useCallback handlers to onMessage or onTunnelMessage. Never put subscribe in effect deps.

    3. 3

      Use publishToChannel or publishToUserTunnel after DB commit — not client publish() for authoritative state.

    4. 4

      curl http://localhost:3000/api/tunnel/test — confirm deploy target and hub mode.

    Transports (alpha)

    TransportWhen used
    Native WebSocketRING_DEPLOY_TARGET is k8s or self-hosted — primary on custom server.ts
    SSEFallback and primary on RING_DEPLOY_TARGET=vercel
    Long-pollingUltimate fallback via /api/tunnel/poll
    Supabase RealtimeWhen NEXT_PUBLIC_TUNNEL_TRANSPORT=supabase and Supabase URL/anon key are set

    TransportManager picks the first working provider in the configured fallback chain. Tunnel realtime is orthogonal to DB_BACKEND_MODE — see Backend modes and databases.

    Configuration

    Verify:

    Tunnel timing and priority routes

    lib/tunnel/tunnel-timing.ts controls when TunnelProvider auto-connects. Default strategy is progressive (immediate on desktop, ~500ms delay on mobile). Routes in priorityRoutes connect after authRoutesDelay (~50ms); routes in deferredRoutes require manual connect.

    priorityRoutes (default)deferredRoutes (default)
    /profile, /wallet, /notifications, /dashboard, /admin/docs, /about, /contact, /blog, /login, /auth/status

    2026-07-07 fixes:

    • /admin in priorityRoutes — admin analytics/forensics sessions get a live tunnel instead of staying on the HTTP-only boot path.
    • matchesRoutePrefix() — prefix match (/admin matches /admin/analytics). Replaced exact .includes() checks that missed nested admin paths.
    • normalizeTunnelRoute() — strips locale prefix (/en/notifications → /notifications) before matching.

    Override via env (optional): NEXT_PUBLIC_TUNNEL_TIMING_STRATEGY, NEXT_PUBLIC_TUNNEL_PRIORITY_ROUTES (comma-separated).

    Boot race, offline queue, and publisher logging

    During login, DeviceTelemetryProvider may POST /api/analytics/device before the tunnel socket is live. Server publishToUserTunnel then sees sse=false, ws=false — the hub queues the message instead of dropping it.

    Offline queue drain on connect

    lib/tunnel/native-ws/attach.ts drains hub.drainUserQueue(verified.userId) immediately after sending { op: 'auth_ok' } — same offline queue SSE already drained, so WSS no longer waits for a second SSE session to deliver boot-race messages.

    HTTP fetch dedup (not tunnel channels)

    Some sidebar/provider mounts are not tunnel subscriptions — they coalesce duplicate REST calls across React Strict Mode remounts and layout breakpoint swaps:

    ConcernModuleEndpointPattern
    Vendor sidebar gatehooks/use-vendor-status.tsGET /api/vendor/statusModule-scope single-flight + 30s TTL keyed by userId
    Credit balance bootstraphooks/use-credit-balance.tsGET /api/wallet/credit/balanceModule-scope single-flight + 5s TTL keyed by userId for initial bootstrap only; refresh() bypasses cache; live updates via useTunnelChannel('credit:balance')

    Documented in lib/tunnel/SUBSCRIPTION-SSOT.md and hooks/HOOKS-README.md Provider matrix.

    Channel patterns

    PatternExamplePublisherConsumer hook / module
    Per-user inboxnotifications:inboxnotification-serviceuse-notifications.ts, use-realtime.ts
    Per-user inboxcredit:balancecreditBalanceServiceCreditBalanceProvider → use-credit-balance.ts — bootstrap fetchCreditBalanceBootstrap() (5s TTL, userId-keyed); tunnel via useTunnelChannel (userScoped: false)
    Per-user inboxaccount:statusapp/_actions/admin-account-status.tsGlobalTunnelListeners → AccountStatusTunnelListener (userScoped: false)
    HTTP (not tunnel)vendor sidebar—useVendorStatus() → GET /api/vendor/status (30s TTL, single-flight)
    Conversationconversation:${id}chat servicesuse-messaging.ts
    Discoveryopportunities, entitiessync-discovery.tsuse-realtime-opportunities.ts, useSync
    Matchermatchermatcher servicesmatcher moderation UI

    Subscribe to the base channel name on the client. User scoping is enforced by server delivery (publishToUserTunnel / TunnelHub.publishToUser), not by suffixing :userId in useTunnelChannel.

    Removed deprecated hooks

    The following were removed from hooks/use-tunnel.ts (see lib/tunnel/SUBSCRIPTION-SSOT.md):

    RemovedSuperseded by
    useTunnelNotifications()useTunnelChannel({ channel: 'notifications:inbox' })
    useTunnelMessages(channel)useTunnelChannel + use-messaging.ts for chat
    useTunnelPresence(channel)useTunnelChannel({ channel: 'presence', onTunnelMessage })
    text
    
    server.ts → Next handler + attachTunnelWss (k8s/self-hosted)
    TUNNEL_HUB_MODE=k8s-postgres → getTunnelHub() → InMemoryTunnelHub
    Server services → lib/tunnel/publisher.ts → getTunnelHub()
    /api/tunnel/* → getTunnelHub()
    Client → TunnelProvider → channel-subscription-registry → TransportManager → WSS | SSE | poll
    Features → useTunnelChannel (preferred) | useSync (REST + tunnel refresh)
    typescript
    
    import { publishToChannel } from '@/lib/tunnel/publisher'
    
    await publishToChannel(`conversation:${conversationId}`, 'message:new', message)
    await publishToChannel('opportunities', 'opportunity:created', { id })
    typescript
    
    import { publishToUserTunnel } from '@/lib/tunnel/publisher'
    
    await publishToUserTunnel(userId, 'notifications:inbox', { action: 'notification', notification })
    tsx
    
    // components/providers/app-client-shell.tsx (simplified)
    <TunnelProvider autoConnect={false} debug={false}>
      <CreditBalanceProvider>
        <GlobalTunnelListeners />  {/* AccountStatusTunnelListener */}
        <Web3ScopeProvider>
          {/* …children, GoogleOneTap, StoreProvider… */}
        </Web3ScopeProvider>
      </CreditBalanceProvider>
    </TunnelProvider>
    typescript
    
    'use client'
    
    import { useCallback } from 'react'
    import { useTunnelChannel } from '@/hooks/use-tunnel-channel'
    import type { TunnelMessage } from '@/lib/tunnel/types'
    
    export function OpportunityLiveListener() {
      const handleUpdate = useCallback((message: TunnelMessage) => {
        const update = message.payload
        // merge into list state or dispatch window event
      }, [])
    
      useTunnelChannel({
        channel: 'opportunities',
        enabled: true,
        onTunnelMessage: handleUpdate,
      })
    
      return null
    }
    typescript
    
    useTunnelChannel<CreditBalanceData>({
      channel: 'credit:balance',
      userScoped: false, // server scopes via publishToUserTunnel — do not suffix :userId on client
      onMessage: (payload) => setBalance(payload),
    })
    typescript
    
    import { useSync } from '@/hooks/use-sync'
    
    const { data, usingTunnel, tunnelConnected } = useSync({
      fetcher: () => fetch('/api/notifications/unread-count').then((r) => r.json()),
      tunnel: {
        channel: 'notifications:unread',
        enabled: true,
        onMessage: (message) => ({
          nextData: message.payload as { count: number },
        }),
      },
    })
    bash
    
    RING_DEPLOY_TARGET=k8s
    NEXT_PUBLIC_RING_DEPLOY_TARGET=k8s
    TUNNEL_HUB_MODE=k8s-postgres
    NEXT_PUBLIC_TUNNEL_TRANSPORT=auto
    # NEXT_PUBLIC_TUNNEL_WS_URL=wss://your-host/api/tunnel/ws
    bash
    
    curl "http://localhost:3000/api/tunnel/test"

    Account enforcement

    Suspend/reactivate pushes on account:status redirect or refresh the session without a manual logout.

    Credit balance

    Wallet and subscription UI reflects chip-ins and spend limits in real time on credit:balance — one shared subscription via CreditBalanceProvider inside TunnelProvider.

    Operator expectations

    • Single replica today — TUNNEL_HUB_MODE=k8s-postgres uses in-process fan-out; scale-out requires backlog hub modes (widgets below).
    • FCM is the offline path — tunnel covers active tabs only.
    • Stale UI after deploy — check /api/tunnel/test and browser network for repeated subscribe calls (should be one per channel per session).
    POST
    Channel unsubscription
    /api/tunnel/publishPOSTAuthenticated publish (admin/tools)
    /api/tunnel/testGETEnvironment and provider diagnostics

    Server publishing

    Topic fan-out (chat, discovery, matcher):

    Per-user inbox (unread counts, credit balance):

    lib/discovery/sync-discovery.ts wraps publishToChannel for opportunity and entity CRUD — see Discovery mutation sync.

    Provider tree (AppClientShell)

    Tunnel consumers must be descendants of TunnelProvider. CreditBalanceProvider and GlobalTunnelListeners mount inside the provider so they share one registry (fix 2026-06-23):

    AccountStatusTunnelListener lives under GlobalTunnelListeners — do not delete it; add future global listeners there instead of mounting siblings directly in AppClientShell. Root shell uses next/navigation (usePathname, useRouter); locale resolution is localeFromPathname from lib/pathname-without-locale.ts (same contract as GoogleOneTap).

    Client subscriptions (SSOT)

    Wrap the app in TunnelProvider (AppClientShell). Prefer useTunnelChannel for feature subscriptions:

    Payload + state hook (credit balance pattern):

    Production uses CreditBalanceProvider (components/providers/credit-balance-provider.tsx) — one useCreditBalance() call, many context consumers.

    REST fetch + tunnel-driven refresh (notifications unread, entity lists):

    Production chat uses hooks/use-messaging.ts (useMessages, useTyping) — both delegate to useTunnelChannel on conversation:${id}.

    1. 1

      Confirm TunnelProvider wraps CreditBalanceProvider, GlobalTunnelListeners, and route children. No standalone useTunnel({ autoConnect: true }) parallel to the app provider.

    2. 2

      Pass stable useCallback handlers to onMessage or onTunnelMessage. Never put subscribe in effect deps.

    3. 3

      Use publishToChannel or publishToUserTunnel after DB commit — not client publish() for authoritative state.

    4. 4

      curl http://localhost:3000/api/tunnel/test — confirm deploy target and hub mode.

    Transports (alpha)

    TransportWhen used
    Native WebSocketRING_DEPLOY_TARGET is k8s or self-hosted — primary on custom server.ts
    SSEFallback and primary on RING_DEPLOY_TARGET=vercel
    Long-pollingUltimate fallback via /api/tunnel/poll
    Supabase RealtimeWhen NEXT_PUBLIC_TUNNEL_TRANSPORT=supabase and Supabase URL/anon key are set

    TransportManager picks the first working provider in the configured fallback chain. Tunnel realtime is orthogonal to DB_BACKEND_MODE — see Backend modes and databases.

    Configuration

    Verify:

    Tunnel timing and priority routes

    lib/tunnel/tunnel-timing.ts controls when TunnelProvider auto-connects. Default strategy is progressive (immediate on desktop, ~500ms delay on mobile). Routes in priorityRoutes connect after authRoutesDelay (~50ms); routes in deferredRoutes require manual connect.

    priorityRoutes (default)deferredRoutes (default)
    /profile, /wallet, /notifications, /dashboard, /admin/docs, /about, /contact, /blog, /login, /auth/status

    2026-07-07 fixes:

    • /admin in priorityRoutes — admin analytics/forensics sessions get a live tunnel instead of staying on the HTTP-only boot path.
    • matchesRoutePrefix() — prefix match (/admin matches /admin/analytics). Replaced exact .includes() checks that missed nested admin paths.
    • normalizeTunnelRoute() — strips locale prefix (/en/notifications → /notifications) before matching.

    Override via env (optional): NEXT_PUBLIC_TUNNEL_TIMING_STRATEGY, NEXT_PUBLIC_TUNNEL_PRIORITY_ROUTES (comma-separated).

    Boot race, offline queue, and publisher logging

    During login, DeviceTelemetryProvider may POST /api/analytics/device before the tunnel socket is live. Server publishToUserTunnel then sees sse=false, ws=false — the hub queues the message instead of dropping it.

    Offline queue drain on connect

    lib/tunnel/native-ws/attach.ts drains hub.drainUserQueue(verified.userId) immediately after sending { op: 'auth_ok' } — same offline queue SSE already drained, so WSS no longer waits for a second SSE session to deliver boot-race messages.

    HTTP fetch dedup (not tunnel channels)

    Some sidebar/provider mounts are not tunnel subscriptions — they coalesce duplicate REST calls across React Strict Mode remounts and layout breakpoint swaps:

    ConcernModuleEndpointPattern
    Vendor sidebar gatehooks/use-vendor-status.tsGET /api/vendor/statusModule-scope single-flight + 30s TTL keyed by userId
    Credit balance bootstraphooks/use-credit-balance.tsGET /api/wallet/credit/balanceModule-scope single-flight + 5s TTL keyed by userId for initial bootstrap only; refresh() bypasses cache; live updates via useTunnelChannel('credit:balance')

    Documented in lib/tunnel/SUBSCRIPTION-SSOT.md and hooks/HOOKS-README.md Provider matrix.

    Channel patterns

    PatternExamplePublisherConsumer hook / module
    Per-user inboxnotifications:inboxnotification-serviceuse-notifications.ts, use-realtime.ts
    Per-user inboxcredit:balancecreditBalanceServiceCreditBalanceProvider → use-credit-balance.ts — bootstrap fetchCreditBalanceBootstrap() (5s TTL, userId-keyed); tunnel via useTunnelChannel (userScoped: false)
    Per-user inboxaccount:statusapp/_actions/admin-account-status.tsGlobalTunnelListeners → AccountStatusTunnelListener (userScoped: false)
    HTTP (not tunnel)vendor sidebar—useVendorStatus() → GET /api/vendor/status (30s TTL, single-flight)
    Conversationconversation:${id}chat servicesuse-messaging.ts
    Discoveryopportunities, entitiessync-discovery.tsuse-realtime-opportunities.ts, useSync
    Matchermatchermatcher servicesmatcher moderation UI

    Subscribe to the base channel name on the client. User scoping is enforced by server delivery (publishToUserTunnel / TunnelHub.publishToUser), not by suffixing :userId in useTunnelChannel.

    Removed deprecated hooks

    The following were removed from hooks/use-tunnel.ts (see lib/tunnel/SUBSCRIPTION-SSOT.md):

    RemovedSuperseded by
    useTunnelNotifications()useTunnelChannel({ channel: 'notifications:inbox' })
    useTunnelMessages(channel)useTunnelChannel + use-messaging.ts for chat
    useTunnelPresence(channel)useTunnelChannel({ channel: 'presence', onTunnelMessage })
    text
    
    server.ts → Next handler + attachTunnelWss (k8s/self-hosted)
    TUNNEL_HUB_MODE=k8s-postgres → getTunnelHub() → InMemoryTunnelHub
    Server services → lib/tunnel/publisher.ts → getTunnelHub()
    /api/tunnel/* → getTunnelHub()
    Client → TunnelProvider → channel-subscription-registry → TransportManager → WSS | SSE | poll
    Features → useTunnelChannel (preferred) | useSync (REST + tunnel refresh)
    typescript
    
    import { publishToChannel } from '@/lib/tunnel/publisher'
    
    await publishToChannel(`conversation:${conversationId}`, 'message:new', message)
    await publishToChannel('opportunities', 'opportunity:created', { id })
    typescript
    
    import { publishToUserTunnel } from '@/lib/tunnel/publisher'
    
    await publishToUserTunnel(userId, 'notifications:inbox', { action: 'notification', notification })
    tsx
    
    // components/providers/app-client-shell.tsx (simplified)
    <TunnelProvider autoConnect={false} debug={false}>
      <CreditBalanceProvider>
        <GlobalTunnelListeners />  {/* AccountStatusTunnelListener */}
        <Web3ScopeProvider>
          {/* …children, GoogleOneTap, StoreProvider… */}
        </Web3ScopeProvider>
      </CreditBalanceProvider>
    </TunnelProvider>
    typescript
    
    'use client'
    
    import { useCallback } from 'react'
    import { useTunnelChannel } from '@/hooks/use-tunnel-channel'
    import type { TunnelMessage } from '@/lib/tunnel/types'
    
    export function OpportunityLiveListener() {
      const handleUpdate = useCallback((message: TunnelMessage) => {
        const update = message.payload
        // merge into list state or dispatch window event
      }, [])
    
      useTunnelChannel({
        channel: 'opportunities',
        enabled: true,
        onTunnelMessage: handleUpdate,
      })
    
      return null
    }
    typescript
    
    useTunnelChannel<CreditBalanceData>({
      channel: 'credit:balance',
      userScoped: false, // server scopes via publishToUserTunnel — do not suffix :userId on client
      onMessage: (payload) => setBalance(payload),
    })
    typescript
    
    import { useSync } from '@/hooks/use-sync'
    
    const { data, usingTunnel, tunnelConnected } = useSync({
      fetcher: () => fetch('/api/notifications/unread-count').then((r) => r.json()),
      tunnel: {
        channel: 'notifications:unread',
        enabled: true,
        onMessage: (message) => ({
          nextData: message.payload as { count: number },
        }),
      },
    })
    bash
    
    RING_DEPLOY_TARGET=k8s
    NEXT_PUBLIC_RING_DEPLOY_TARGET=k8s
    TUNNEL_HUB_MODE=k8s-postgres
    NEXT_PUBLIC_TUNNEL_TRANSPORT=auto
    # NEXT_PUBLIC_TUNNEL_WS_URL=wss://your-host/api/tunnel/ws
    bash
    
    curl "http://localhost:3000/api/tunnel/test"

    Account enforcement

    Suspend/reactivate pushes on account:status redirect or refresh the session without a manual logout.

    Credit balance

    Wallet and subscription UI reflects chip-ins and spend limits in real time on credit:balance — one shared subscription via CreditBalanceProvider inside TunnelProvider.

    Operator expectations

    • Single replica today — TUNNEL_HUB_MODE=k8s-postgres uses in-process fan-out; scale-out requires backlog hub modes (widgets below).
    • FCM is the offline path — tunnel covers active tabs only.
    • Stale UI after deploy — check /api/tunnel/test and browser network for repeated subscribe calls (should be one per channel per session).
    POST
    Channel unsubscription
    /api/tunnel/publishPOSTAuthenticated publish (admin/tools)
    /api/tunnel/testGETEnvironment and provider diagnostics

    Server publishing

    Topic fan-out (chat, discovery, matcher):

    Per-user inbox (unread counts, credit balance):

    lib/discovery/sync-discovery.ts wraps publishToChannel for opportunity and entity CRUD — see Discovery mutation sync.

    Provider tree (AppClientShell)

    Tunnel consumers must be descendants of TunnelProvider. CreditBalanceProvider and GlobalTunnelListeners mount inside the provider so they share one registry (fix 2026-06-23):

    AccountStatusTunnelListener lives under GlobalTunnelListeners — do not delete it; add future global listeners there instead of mounting siblings directly in AppClientShell. Root shell uses next/navigation (usePathname, useRouter); locale resolution is localeFromPathname from lib/pathname-without-locale.ts (same contract as GoogleOneTap).

    Client subscriptions (SSOT)

    Wrap the app in TunnelProvider (AppClientShell). Prefer useTunnelChannel for feature subscriptions:

    Payload + state hook (credit balance pattern):

    Production uses CreditBalanceProvider (components/providers/credit-balance-provider.tsx) — one useCreditBalance() call, many context consumers.

    REST fetch + tunnel-driven refresh (notifications unread, entity lists):

    Production chat uses hooks/use-messaging.ts (useMessages, useTyping) — both delegate to useTunnelChannel on conversation:${id}.

    1. 1

      Confirm TunnelProvider wraps CreditBalanceProvider, GlobalTunnelListeners, and route children. No standalone useTunnel({ autoConnect: true }) parallel to the app provider.

    2. 2

      Pass stable useCallback handlers to onMessage or onTunnelMessage. Never put subscribe in effect deps.

    3. 3

      Use publishToChannel or publishToUserTunnel after DB commit — not client publish() for authoritative state.

    4. 4

      curl http://localhost:3000/api/tunnel/test — confirm deploy target and hub mode.

    Transports (alpha)

    TransportWhen used
    Native WebSocketRING_DEPLOY_TARGET is k8s or self-hosted — primary on custom server.ts
    SSEFallback and primary on RING_DEPLOY_TARGET=vercel
    Long-pollingUltimate fallback via /api/tunnel/poll
    Supabase RealtimeWhen NEXT_PUBLIC_TUNNEL_TRANSPORT=supabase and Supabase URL/anon key are set

    TransportManager picks the first working provider in the configured fallback chain. Tunnel realtime is orthogonal to DB_BACKEND_MODE — see Backend modes and databases.

    Configuration

    Verify:

    Tunnel timing and priority routes

    lib/tunnel/tunnel-timing.ts controls when TunnelProvider auto-connects. Default strategy is progressive (immediate on desktop, ~500ms delay on mobile). Routes in priorityRoutes connect after authRoutesDelay (~50ms); routes in deferredRoutes require manual connect.

    priorityRoutes (default)deferredRoutes (default)
    /profile, /wallet, /notifications, /dashboard, /admin/docs, /about, /contact, /blog, /login, /auth/status

    2026-07-07 fixes:

    • /admin in priorityRoutes — admin analytics/forensics sessions get a live tunnel instead of staying on the HTTP-only boot path.
    • matchesRoutePrefix() — prefix match (/admin matches /admin/analytics). Replaced exact .includes() checks that missed nested admin paths.
    • normalizeTunnelRoute() — strips locale prefix (/en/notifications → /notifications) before matching.

    Override via env (optional): NEXT_PUBLIC_TUNNEL_TIMING_STRATEGY, NEXT_PUBLIC_TUNNEL_PRIORITY_ROUTES (comma-separated).

    Boot race, offline queue, and publisher logging

    During login, DeviceTelemetryProvider may POST /api/analytics/device before the tunnel socket is live. Server publishToUserTunnel then sees sse=false, ws=false — the hub queues the message instead of dropping it.

    Offline queue drain on connect

    lib/tunnel/native-ws/attach.ts drains hub.drainUserQueue(verified.userId) immediately after sending { op: 'auth_ok' } — same offline queue SSE already drained, so WSS no longer waits for a second SSE session to deliver boot-race messages.

    HTTP fetch dedup (not tunnel channels)

    Some sidebar/provider mounts are not tunnel subscriptions — they coalesce duplicate REST calls across React Strict Mode remounts and layout breakpoint swaps:

    ConcernModuleEndpointPattern
    Vendor sidebar gatehooks/use-vendor-status.tsGET /api/vendor/statusModule-scope single-flight + 30s TTL keyed by userId
    Credit balance bootstraphooks/use-credit-balance.tsGET /api/wallet/credit/balanceModule-scope single-flight + 5s TTL keyed by userId for initial bootstrap only; refresh() bypasses cache; live updates via useTunnelChannel('credit:balance')

    Documented in lib/tunnel/SUBSCRIPTION-SSOT.md and hooks/HOOKS-README.md Provider matrix.

    Channel patterns

    PatternExamplePublisherConsumer hook / module
    Per-user inboxnotifications:inboxnotification-serviceuse-notifications.ts, use-realtime.ts
    Per-user inboxcredit:balancecreditBalanceServiceCreditBalanceProvider → use-credit-balance.ts — bootstrap fetchCreditBalanceBootstrap() (5s TTL, userId-keyed); tunnel via useTunnelChannel (userScoped: false)
    Per-user inboxaccount:statusapp/_actions/admin-account-status.tsGlobalTunnelListeners → AccountStatusTunnelListener (userScoped: false)
    HTTP (not tunnel)vendor sidebar—useVendorStatus() → GET /api/vendor/status (30s TTL, single-flight)
    Conversationconversation:${id}chat servicesuse-messaging.ts
    Discoveryopportunities, entitiessync-discovery.tsuse-realtime-opportunities.ts, useSync
    Matchermatchermatcher servicesmatcher moderation UI

    Subscribe to the base channel name on the client. User scoping is enforced by server delivery (publishToUserTunnel / TunnelHub.publishToUser), not by suffixing :userId in useTunnelChannel.

    Removed deprecated hooks

    The following were removed from hooks/use-tunnel.ts (see lib/tunnel/SUBSCRIPTION-SSOT.md):

    RemovedSuperseded by
    useTunnelNotifications()useTunnelChannel({ channel: 'notifications:inbox' })
    useTunnelMessages(channel)useTunnelChannel + use-messaging.ts for chat
    useTunnelPresence(channel)useTunnelChannel({ channel: 'presence', onTunnelMessage })
    text
    
    server.ts → Next handler + attachTunnelWss (k8s/self-hosted)
    TUNNEL_HUB_MODE=k8s-postgres → getTunnelHub() → InMemoryTunnelHub
    Server services → lib/tunnel/publisher.ts → getTunnelHub()
    /api/tunnel/* → getTunnelHub()
    Client → TunnelProvider → channel-subscription-registry → TransportManager → WSS | SSE | poll
    Features → useTunnelChannel (preferred) | useSync (REST + tunnel refresh)
    typescript
    
    import { publishToChannel } from '@/lib/tunnel/publisher'
    
    await publishToChannel(`conversation:${conversationId}`, 'message:new', message)
    await publishToChannel('opportunities', 'opportunity:created', { id })
    typescript
    
    import { publishToUserTunnel } from '@/lib/tunnel/publisher'
    
    await publishToUserTunnel(userId, 'notifications:inbox', { action: 'notification', notification })
    tsx
    
    // components/providers/app-client-shell.tsx (simplified)
    <TunnelProvider autoConnect={false} debug={false}>
      <CreditBalanceProvider>
        <GlobalTunnelListeners />  {/* AccountStatusTunnelListener */}
        <Web3ScopeProvider>
          {/* …children, GoogleOneTap, StoreProvider… */}
        </Web3ScopeProvider>
      </CreditBalanceProvider>
    </TunnelProvider>
    typescript
    
    'use client'
    
    import { useCallback } from 'react'
    import { useTunnelChannel } from '@/hooks/use-tunnel-channel'
    import type { TunnelMessage } from '@/lib/tunnel/types'
    
    export function OpportunityLiveListener() {
      const handleUpdate = useCallback((message: TunnelMessage) => {
        const update = message.payload
        // merge into list state or dispatch window event
      }, [])
    
      useTunnelChannel({
        channel: 'opportunities',
        enabled: true,
        onTunnelMessage: handleUpdate,
      })
    
      return null
    }
    typescript
    
    useTunnelChannel<CreditBalanceData>({
      channel: 'credit:balance',
      userScoped: false, // server scopes via publishToUserTunnel — do not suffix :userId on client
      onMessage: (payload) => setBalance(payload),
    })
    typescript
    
    import { useSync } from '@/hooks/use-sync'
    
    const { data, usingTunnel, tunnelConnected } = useSync({
      fetcher: () => fetch('/api/notifications/unread-count').then((r) => r.json()),
      tunnel: {
        channel: 'notifications:unread',
        enabled: true,
        onMessage: (message) => ({
          nextData: message.payload as { count: number },
        }),
      },
    })
    bash
    
    RING_DEPLOY_TARGET=k8s
    NEXT_PUBLIC_RING_DEPLOY_TARGET=k8s
    TUNNEL_HUB_MODE=k8s-postgres
    NEXT_PUBLIC_TUNNEL_TRANSPORT=auto
    # NEXT_PUBLIC_TUNNEL_WS_URL=wss://your-host/api/tunnel/ws
    bash
    
    curl "http://localhost:3000/api/tunnel/test"