Підготовка контенту платформи Ring
Підготовка контенту платформи Ring
Підготовка контенту платформи Ring
Concepts, value, and typical clone scenarios — less code.
Concepts, value, and typical clone scenarios — less code.
Concepts, value, and typical clone scenarios — less code.
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.
| Layer | Module | Role |
|---|---|---|
| Transport dedup | lib/tunnel/channel-subscription-registry.ts | One transport subscribe per channel; pending-promise guard |
| App hook | hooks/use-tunnel-channel.ts | Stable refs; effect deps [resolvedChannel, isConnected] only |
| Provider | components/providers/tunnel-provider.tsx | Shared connection + registry instance |
| Connect timing | lib/tunnel/tunnel-timing.ts | Progressive connect; priorityRoutes / deferredRoutes via matchesRoutePrefix() |
| Root listeners | components/providers/global-tunnel-listeners.tsx | Single mount point for global tunnel side-effects (AccountStatusTunnelListener, …) |
| HTTP dedup (non-tunnel) | hooks/use-vendor-status.ts, hooks/use-credit-balance.ts | Single-flight REST guards for sidebar Strict Mode / provider remounts |
| Locale SSOT | lib/pathname-without-locale.ts | localeFromPathname for root shell (next/navigation, not @/i18n/routing) |
| Campaign SSOT | lib/tunnel/SUBSCRIPTION-SSOT.md | Deprecated 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".
Your clone feels alive: unread badges update, chat messages appear, marketplace cards refresh after someone posts — without asking users to reload.
New opportunity matches, order updates, and admin alerts appear in the bell icon while the user stays on the page.
Members see replies and typing indicators on conversation:* channels — critical for vendor and matcher workflows.
When a listing is approved, open /opportunities tabs update via the opportunities channel.
memory and k8s-postgres use InMemoryTunnelHub; correct for one replica. Horizontal scale requires backlog hub modes below.TunnelHub broker flow, offline queue drain, and feature publish/subscribe matrix.
Topic channel publishes after opportunity and entity CRUD.
Inbox REST + notifications:inbox tunnel channel.
conversation:${id} channel patterns for live chat.
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.
| Layer | Module | Role |
|---|---|---|
| Transport dedup | lib/tunnel/channel-subscription-registry.ts | One transport subscribe per channel; pending-promise guard |
| App hook | hooks/use-tunnel-channel.ts | Stable refs; effect deps [resolvedChannel, isConnected] only |
| Provider | components/providers/tunnel-provider.tsx | Shared connection + registry instance |
| Connect timing | lib/tunnel/tunnel-timing.ts | Progressive connect; priorityRoutes / deferredRoutes via matchesRoutePrefix() |
| Root listeners | components/providers/global-tunnel-listeners.tsx | Single mount point for global tunnel side-effects (AccountStatusTunnelListener, …) |
| HTTP dedup (non-tunnel) | hooks/use-vendor-status.ts, hooks/use-credit-balance.ts | Single-flight REST guards for sidebar Strict Mode / provider remounts |
| Locale SSOT | lib/pathname-without-locale.ts | localeFromPathname for root shell (next/navigation, not @/i18n/routing) |
| Campaign SSOT | lib/tunnel/SUBSCRIPTION-SSOT.md | Deprecated 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".
Your clone feels alive: unread badges update, chat messages appear, marketplace cards refresh after someone posts — without asking users to reload.
New opportunity matches, order updates, and admin alerts appear in the bell icon while the user stays on the page.
Members see replies and typing indicators on conversation:* channels — critical for vendor and matcher workflows.
When a listing is approved, open /opportunities tabs update via the opportunities channel.
memory and k8s-postgres use InMemoryTunnelHub; correct for one replica. Horizontal scale requires backlog hub modes below.TunnelHub broker flow, offline queue drain, and feature publish/subscribe matrix.
Topic channel publishes after opportunity and entity CRUD.
Inbox REST + notifications:inbox tunnel channel.
conversation:${id} channel patterns for live chat.
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.
| Layer | Module | Role |
|---|---|---|
| Transport dedup | lib/tunnel/channel-subscription-registry.ts | One transport subscribe per channel; pending-promise guard |
| App hook | hooks/use-tunnel-channel.ts | Stable refs; effect deps [resolvedChannel, isConnected] only |
| Provider | components/providers/tunnel-provider.tsx | Shared connection + registry instance |
| Connect timing | lib/tunnel/tunnel-timing.ts | Progressive connect; priorityRoutes / deferredRoutes via matchesRoutePrefix() |
| Root listeners | components/providers/global-tunnel-listeners.tsx | Single mount point for global tunnel side-effects (AccountStatusTunnelListener, …) |
| HTTP dedup (non-tunnel) | hooks/use-vendor-status.ts, hooks/use-credit-balance.ts | Single-flight REST guards for sidebar Strict Mode / provider remounts |
| Locale SSOT | lib/pathname-without-locale.ts | localeFromPathname for root shell (next/navigation, not @/i18n/routing) |
| Campaign SSOT | lib/tunnel/SUBSCRIPTION-SSOT.md | Deprecated 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".
Your clone feels alive: unread badges update, chat messages appear, marketplace cards refresh after someone posts — without asking users to reload.
New opportunity matches, order updates, and admin alerts appear in the bell icon while the user stays on the page.
Members see replies and typing indicators on conversation:* channels — critical for vendor and matcher workflows.
When a listing is approved, open /opportunities tabs update via the opportunities channel.
memory and k8s-postgres use InMemoryTunnelHub; correct for one replica. Horizontal scale requires backlog hub modes below.TunnelHub broker flow, offline queue drain, and feature publish/subscribe matrix.
Topic channel publishes after opportunity and entity CRUD.
Inbox REST + notifications:inbox tunnel channel.
conversation:${id} channel patterns for live chat.
Suspend/reactivate pushes on account:status redirect or refresh the session without a manual logout.
TUNNEL_HUB_MODE=k8s-postgres uses in-process fan-out; scale-out requires backlog hub modes (widgets below)./api/tunnel/test and browser network for repeated subscribe calls (should be one per channel per session).| POST |
| Channel unsubscription |
/api/tunnel/publish | POST | Authenticated publish (admin/tools) |
/api/tunnel/test | GET | Environment and provider diagnostics |
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.
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).
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}.
Confirm TunnelProvider wraps CreditBalanceProvider, GlobalTunnelListeners, and route children. No standalone useTunnel({ autoConnect: true }) parallel to the app provider.
Pass stable useCallback handlers to onMessage or onTunnelMessage. Never put subscribe in effect deps.
Use publishToChannel or publishToUserTunnel after DB commit — not client publish() for authoritative state.
curl http://localhost:3000/api/tunnel/test — confirm deploy target and hub mode.
| Transport | When used |
|---|---|
| Native WebSocket | RING_DEPLOY_TARGET is k8s or self-hosted — primary on custom server.ts |
| SSE | Fallback and primary on RING_DEPLOY_TARGET=vercel |
| Long-polling | Ultimate fallback via /api/tunnel/poll |
| Supabase Realtime | When 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.
Verify:
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).
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.
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.
Some sidebar/provider mounts are not tunnel subscriptions — they coalesce duplicate REST calls across React Strict Mode remounts and layout breakpoint swaps:
| Concern | Module | Endpoint | Pattern |
|---|---|---|---|
| Vendor sidebar gate | hooks/use-vendor-status.ts | GET /api/vendor/status | Module-scope single-flight + 30s TTL keyed by userId |
| Credit balance bootstrap | hooks/use-credit-balance.ts | GET /api/wallet/credit/balance | Module-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.
| Pattern | Example | Publisher | Consumer hook / module |
|---|---|---|---|
| Per-user inbox | notifications:inbox | notification-service | use-notifications.ts, use-realtime.ts |
| Per-user inbox | credit:balance | creditBalanceService | CreditBalanceProvider → use-credit-balance.ts — bootstrap fetchCreditBalanceBootstrap() (5s TTL, userId-keyed); tunnel via useTunnelChannel (userScoped: false) |
| Per-user inbox | account:status | app/_actions/admin-account-status.ts | GlobalTunnelListeners → AccountStatusTunnelListener (userScoped: false) |
| HTTP (not tunnel) | vendor sidebar | — | useVendorStatus() → GET /api/vendor/status (30s TTL, single-flight) |
| Conversation | conversation:${id} | chat services | use-messaging.ts |
| Discovery | opportunities, entities | sync-discovery.ts | use-realtime-opportunities.ts, useSync |
| Matcher | matcher | matcher services | matcher 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.
The following were removed from hooks/use-tunnel.ts (see lib/tunnel/SUBSCRIPTION-SSOT.md):
| Removed | Superseded by |
|---|---|
useTunnelNotifications() | useTunnelChannel({ channel: 'notifications:inbox' }) |
useTunnelMessages(channel) | useTunnelChannel + use-messaging.ts for chat |
useTunnelPresence(channel) | useTunnelChannel({ channel: 'presence', onTunnelMessage }) |
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)
import { publishToChannel } from '@/lib/tunnel/publisher'
await publishToChannel(`conversation:${conversationId}`, 'message:new', message)
await publishToChannel('opportunities', 'opportunity:created', { id })
import { publishToUserTunnel } from '@/lib/tunnel/publisher'
await publishToUserTunnel(userId, 'notifications:inbox', { action: 'notification', notification })
// components/providers/app-client-shell.tsx (simplified)
<TunnelProvider autoConnect={false} debug={false}>
<CreditBalanceProvider>
<GlobalTunnelListeners /> {/* AccountStatusTunnelListener */}
<Web3ScopeProvider>
{/* …children, GoogleOneTap, StoreProvider… */}
</Web3ScopeProvider>
</CreditBalanceProvider>
</TunnelProvider>
'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
}
useTunnelChannel<CreditBalanceData>({
channel: 'credit:balance',
userScoped: false, // server scopes via publishToUserTunnel — do not suffix :userId on client
onMessage: (payload) => setBalance(payload),
})
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 },
}),
},
})
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
curl "http://localhost:3000/api/tunnel/test"Suspend/reactivate pushes on account:status redirect or refresh the session without a manual logout.
TUNNEL_HUB_MODE=k8s-postgres uses in-process fan-out; scale-out requires backlog hub modes (widgets below)./api/tunnel/test and browser network for repeated subscribe calls (should be one per channel per session).| POST |
| Channel unsubscription |
/api/tunnel/publish | POST | Authenticated publish (admin/tools) |
/api/tunnel/test | GET | Environment and provider diagnostics |
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.
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).
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}.
Confirm TunnelProvider wraps CreditBalanceProvider, GlobalTunnelListeners, and route children. No standalone useTunnel({ autoConnect: true }) parallel to the app provider.
Pass stable useCallback handlers to onMessage or onTunnelMessage. Never put subscribe in effect deps.
Use publishToChannel or publishToUserTunnel after DB commit — not client publish() for authoritative state.
curl http://localhost:3000/api/tunnel/test — confirm deploy target and hub mode.
| Transport | When used |
|---|---|
| Native WebSocket | RING_DEPLOY_TARGET is k8s or self-hosted — primary on custom server.ts |
| SSE | Fallback and primary on RING_DEPLOY_TARGET=vercel |
| Long-polling | Ultimate fallback via /api/tunnel/poll |
| Supabase Realtime | When 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.
Verify:
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).
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.
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.
Some sidebar/provider mounts are not tunnel subscriptions — they coalesce duplicate REST calls across React Strict Mode remounts and layout breakpoint swaps:
| Concern | Module | Endpoint | Pattern |
|---|---|---|---|
| Vendor sidebar gate | hooks/use-vendor-status.ts | GET /api/vendor/status | Module-scope single-flight + 30s TTL keyed by userId |
| Credit balance bootstrap | hooks/use-credit-balance.ts | GET /api/wallet/credit/balance | Module-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.
| Pattern | Example | Publisher | Consumer hook / module |
|---|---|---|---|
| Per-user inbox | notifications:inbox | notification-service | use-notifications.ts, use-realtime.ts |
| Per-user inbox | credit:balance | creditBalanceService | CreditBalanceProvider → use-credit-balance.ts — bootstrap fetchCreditBalanceBootstrap() (5s TTL, userId-keyed); tunnel via useTunnelChannel (userScoped: false) |
| Per-user inbox | account:status | app/_actions/admin-account-status.ts | GlobalTunnelListeners → AccountStatusTunnelListener (userScoped: false) |
| HTTP (not tunnel) | vendor sidebar | — | useVendorStatus() → GET /api/vendor/status (30s TTL, single-flight) |
| Conversation | conversation:${id} | chat services | use-messaging.ts |
| Discovery | opportunities, entities | sync-discovery.ts | use-realtime-opportunities.ts, useSync |
| Matcher | matcher | matcher services | matcher 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.
The following were removed from hooks/use-tunnel.ts (see lib/tunnel/SUBSCRIPTION-SSOT.md):
| Removed | Superseded by |
|---|---|
useTunnelNotifications() | useTunnelChannel({ channel: 'notifications:inbox' }) |
useTunnelMessages(channel) | useTunnelChannel + use-messaging.ts for chat |
useTunnelPresence(channel) | useTunnelChannel({ channel: 'presence', onTunnelMessage }) |
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)
import { publishToChannel } from '@/lib/tunnel/publisher'
await publishToChannel(`conversation:${conversationId}`, 'message:new', message)
await publishToChannel('opportunities', 'opportunity:created', { id })
import { publishToUserTunnel } from '@/lib/tunnel/publisher'
await publishToUserTunnel(userId, 'notifications:inbox', { action: 'notification', notification })
// components/providers/app-client-shell.tsx (simplified)
<TunnelProvider autoConnect={false} debug={false}>
<CreditBalanceProvider>
<GlobalTunnelListeners /> {/* AccountStatusTunnelListener */}
<Web3ScopeProvider>
{/* …children, GoogleOneTap, StoreProvider… */}
</Web3ScopeProvider>
</CreditBalanceProvider>
</TunnelProvider>
'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
}
useTunnelChannel<CreditBalanceData>({
channel: 'credit:balance',
userScoped: false, // server scopes via publishToUserTunnel — do not suffix :userId on client
onMessage: (payload) => setBalance(payload),
})
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 },
}),
},
})
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
curl "http://localhost:3000/api/tunnel/test"Suspend/reactivate pushes on account:status redirect or refresh the session without a manual logout.
TUNNEL_HUB_MODE=k8s-postgres uses in-process fan-out; scale-out requires backlog hub modes (widgets below)./api/tunnel/test and browser network for repeated subscribe calls (should be one per channel per session).| POST |
| Channel unsubscription |
/api/tunnel/publish | POST | Authenticated publish (admin/tools) |
/api/tunnel/test | GET | Environment and provider diagnostics |
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.
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).
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}.
Confirm TunnelProvider wraps CreditBalanceProvider, GlobalTunnelListeners, and route children. No standalone useTunnel({ autoConnect: true }) parallel to the app provider.
Pass stable useCallback handlers to onMessage or onTunnelMessage. Never put subscribe in effect deps.
Use publishToChannel or publishToUserTunnel after DB commit — not client publish() for authoritative state.
curl http://localhost:3000/api/tunnel/test — confirm deploy target and hub mode.
| Transport | When used |
|---|---|
| Native WebSocket | RING_DEPLOY_TARGET is k8s or self-hosted — primary on custom server.ts |
| SSE | Fallback and primary on RING_DEPLOY_TARGET=vercel |
| Long-polling | Ultimate fallback via /api/tunnel/poll |
| Supabase Realtime | When 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.
Verify:
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).
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.
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.
Some sidebar/provider mounts are not tunnel subscriptions — they coalesce duplicate REST calls across React Strict Mode remounts and layout breakpoint swaps:
| Concern | Module | Endpoint | Pattern |
|---|---|---|---|
| Vendor sidebar gate | hooks/use-vendor-status.ts | GET /api/vendor/status | Module-scope single-flight + 30s TTL keyed by userId |
| Credit balance bootstrap | hooks/use-credit-balance.ts | GET /api/wallet/credit/balance | Module-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.
| Pattern | Example | Publisher | Consumer hook / module |
|---|---|---|---|
| Per-user inbox | notifications:inbox | notification-service | use-notifications.ts, use-realtime.ts |
| Per-user inbox | credit:balance | creditBalanceService | CreditBalanceProvider → use-credit-balance.ts — bootstrap fetchCreditBalanceBootstrap() (5s TTL, userId-keyed); tunnel via useTunnelChannel (userScoped: false) |
| Per-user inbox | account:status | app/_actions/admin-account-status.ts | GlobalTunnelListeners → AccountStatusTunnelListener (userScoped: false) |
| HTTP (not tunnel) | vendor sidebar | — | useVendorStatus() → GET /api/vendor/status (30s TTL, single-flight) |
| Conversation | conversation:${id} | chat services | use-messaging.ts |
| Discovery | opportunities, entities | sync-discovery.ts | use-realtime-opportunities.ts, useSync |
| Matcher | matcher | matcher services | matcher 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.
The following were removed from hooks/use-tunnel.ts (see lib/tunnel/SUBSCRIPTION-SSOT.md):
| Removed | Superseded by |
|---|---|
useTunnelNotifications() | useTunnelChannel({ channel: 'notifications:inbox' }) |
useTunnelMessages(channel) | useTunnelChannel + use-messaging.ts for chat |
useTunnelPresence(channel) | useTunnelChannel({ channel: 'presence', onTunnelMessage }) |
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)
import { publishToChannel } from '@/lib/tunnel/publisher'
await publishToChannel(`conversation:${conversationId}`, 'message:new', message)
await publishToChannel('opportunities', 'opportunity:created', { id })
import { publishToUserTunnel } from '@/lib/tunnel/publisher'
await publishToUserTunnel(userId, 'notifications:inbox', { action: 'notification', notification })
// components/providers/app-client-shell.tsx (simplified)
<TunnelProvider autoConnect={false} debug={false}>
<CreditBalanceProvider>
<GlobalTunnelListeners /> {/* AccountStatusTunnelListener */}
<Web3ScopeProvider>
{/* …children, GoogleOneTap, StoreProvider… */}
</Web3ScopeProvider>
</CreditBalanceProvider>
</TunnelProvider>
'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
}
useTunnelChannel<CreditBalanceData>({
channel: 'credit:balance',
userScoped: false, // server scopes via publishToUserTunnel — do not suffix :userId on client
onMessage: (payload) => setBalance(payload),
})
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 },
}),
},
})
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
curl "http://localhost:3000/api/tunnel/test"