@arqel-dev/realtime 0.15.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -30,8 +30,8 @@ Tipos públicos exportados:
30
30
  - `EchoConfig` — shape aceito por `setupEcho`.
31
31
  - `EchoLike` — subset da instância Echo consumida pelos hooks.
32
32
  - `EchoChannelLike`, `PresenceChannelLike`, `PresenceMember` — formas
33
- de canal que hooks (`useResourceUpdates`, `useResourcePresence`,
34
- `useActionProgress`, `useWidgetRealtime`) podem assumir.
33
+ de canal que o hook `useResourceUpdates` (em `@arqel-dev/hooks`) e
34
+ `useConnectionStatus`/`useFallbackPolling` podem assumir.
35
35
  - `EchoEventListener`, `EchoConnectorLike` — auxiliares.
36
36
 
37
37
  ## Conventions
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { useArqelTranslations } from '@arqel-dev/react/utils';
1
2
  import { Alert, AlertDescription } from '@arqel-dev/ui';
2
3
  import { useState, useRef, useEffect } from 'react';
3
4
  import { router } from '@inertiajs/react';
@@ -132,12 +133,15 @@ function useFallbackPolling(options = {}) {
132
133
  };
133
134
  }, [enabled, intervalMs, onlyKey]);
134
135
  }
135
- var DEFAULT_MESSAGES = {
136
- connected: "",
137
- connecting: "Connecting...",
138
- disconnected: "Connection lost. Reconnecting...",
139
- failed: "Connection failed. Refresh page.",
140
- unavailable: ""
136
+ var STATUS_MESSAGE = {
137
+ connected: { key: "", fallback: "" },
138
+ connecting: { key: "arqel.realtime.connecting", fallback: "Connecting..." },
139
+ disconnected: {
140
+ key: "arqel.realtime.disconnected",
141
+ fallback: "Connection lost. Reconnecting..."
142
+ },
143
+ failed: { key: "arqel.realtime.failed", fallback: "Connection failed. Refresh page." },
144
+ unavailable: { key: "", fallback: "" }
141
145
  };
142
146
  var STATUS_VARIANT = {
143
147
  connected: "default",
@@ -156,6 +160,7 @@ var STATUS_TONE = {
156
160
  function ConnectionStatusBanner(props) {
157
161
  const { pollOnDisconnect, pollIntervalMs, pollOnly, renderBanner, className } = props;
158
162
  const { status, retryCount } = useConnectionStatus();
163
+ const t = useArqelTranslations();
159
164
  const pollingOptions = {
160
165
  enabled: pollOnDisconnect === true && status === "disconnected"
161
166
  };
@@ -177,7 +182,7 @@ function ConnectionStatusBanner(props) {
177
182
  "data-status": status,
178
183
  "data-arqel-connection-banner": "",
179
184
  className: [STATUS_TONE[status], className].filter(Boolean).join(" "),
180
- children: /* @__PURE__ */ jsx(AlertDescription, { children: DEFAULT_MESSAGES[status] })
185
+ children: /* @__PURE__ */ jsx(AlertDescription, { children: t(STATUS_MESSAGE[status].key, STATUS_MESSAGE[status].fallback) })
181
186
  }
182
187
  );
183
188
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/useConnectionStatus.ts","../src/useFallbackPolling.ts","../src/ConnectionStatusBanner.tsx","../src/setupEcho.ts"],"names":["useEffect"],"mappings":"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","file":"index.js","sourcesContent":["import { useEffect, useRef, useState } from 'react';\nimport type { EchoLike } from './types';\n\n/**\n * Estados possíveis da conexão WebSocket reportados pelo Pusher protocol\n * (Reverb fala o mesmo protocolo).\n *\n * - `connected` — handshake OK, recebendo eventos.\n * - `connecting` — tentando conectar (incluindo reconnect attempts).\n * - `disconnected` — perdeu conexão temporariamente; Pusher tentará reconectar.\n * - `failed` — falhou definitivamente; navegador/transporte incompatível.\n * - `unavailable` — `window.Echo` ausente (SSR ou pacote não bootstrapado).\n */\nexport type ConnectionStatus =\n | 'connected'\n | 'connecting'\n | 'disconnected'\n | 'failed'\n | 'unavailable';\n\nexport interface ConnectionStatusValue {\n status: ConnectionStatus;\n /** Timestamp (ms) da última transição para `connected`. */\n lastConnectedAt: number | null;\n /** Quantas vezes a conexão saiu de `connected` desde o mount. */\n retryCount: number;\n}\n\ninterface PusherConnectionLike {\n bind: (event: string, callback: (data?: unknown) => void) => void;\n unbind?: (event: string, callback?: (data?: unknown) => void) => void;\n state?: string;\n}\n\ninterface WindowWithEcho {\n Echo?: EchoLike;\n}\n\nfunction readEcho(): EchoLike | null {\n if (typeof window === 'undefined') return null;\n const w = window as unknown as WindowWithEcho;\n return w.Echo ?? null;\n}\n\nfunction readConnection(echo: EchoLike | null): PusherConnectionLike | null {\n // biome-ignore lint/suspicious/noExplicitAny: pusher-js connection é genuinamente untyped no shape público\n const connection = (echo?.connector as any)?.pusher?.connection;\n if (!connection || typeof connection.bind !== 'function') return null;\n return connection as PusherConnectionLike;\n}\n\nconst POLL_INTERVAL_MS = 250;\nconst POLL_TIMEOUT_MS = 5000;\n\n/**\n * Hook React que expõe o status atual da conexão WebSocket.\n *\n * SSR-safe: sem `window` ou sem `window.Echo`, retorna `'unavailable'`\n * permanente. Quando `window.Echo` aparece tarde (boot async), o hook\n * faz polling curto (≤5s) para se inscrever assim que disponível.\n */\nexport function useConnectionStatus(): ConnectionStatusValue {\n const [value, setValue] = useState<ConnectionStatusValue>(() => {\n if (typeof window === 'undefined') {\n return { status: 'unavailable', lastConnectedAt: null, retryCount: 0 };\n }\n const echo = readEcho();\n const connection = readConnection(echo);\n const initialState = connection?.state ?? 'connecting';\n return {\n status: initialState === 'connected' ? 'connected' : 'connecting',\n lastConnectedAt: initialState === 'connected' ? Date.now() : null,\n retryCount: 0,\n };\n });\n\n const valueRef = useRef(value);\n valueRef.current = value;\n\n useEffect(() => {\n if (typeof window === 'undefined') return;\n\n let disposed = false;\n let pollTimer: ReturnType<typeof setInterval> | null = null;\n let pollDeadline: ReturnType<typeof setTimeout> | null = null;\n let connection: PusherConnectionLike | null = null;\n\n const handlers: Array<{ event: string; cb: (data?: unknown) => void }> = [];\n\n const transition = (next: ConnectionStatus) => {\n setValue((prev) => {\n // Conta retry quando saímos de `connected` para qualquer coisa não-conectada.\n const wasConnected = prev.status === 'connected';\n const stillNotConnected = next !== 'connected';\n const incrementRetry = wasConnected && stillNotConnected;\n return {\n status: next,\n lastConnectedAt: next === 'connected' ? Date.now() : prev.lastConnectedAt,\n retryCount: incrementRetry ? prev.retryCount + 1 : prev.retryCount,\n };\n });\n };\n\n const subscribe = () => {\n const echo = readEcho();\n const conn = readConnection(echo);\n if (!conn) return false;\n\n connection = conn;\n\n const events: ConnectionStatus[] = [\n 'connected',\n 'connecting',\n 'disconnected',\n 'failed',\n 'unavailable',\n ];\n\n for (const event of events) {\n const cb = () => {\n if (disposed) return;\n // `unavailable` do Pusher significa \"transporte indisponível\" — mas\n // mantemos o nosso `'unavailable'` para \"Echo ausente\". Mapeamos o\n // evento Pusher unavailable para o estado homônimo (mesma semântica\n // de UX: nada a fazer).\n transition(event);\n };\n conn.bind(event, cb);\n handlers.push({ event, cb });\n }\n\n // Caso já estejamos conectados quando subscrevemos, normaliza o estado.\n if (conn.state === 'connected' && valueRef.current.status !== 'connected') {\n transition('connected');\n }\n\n return true;\n };\n\n if (!subscribe()) {\n // Echo ainda não disponível — polling curto até aparecer.\n pollTimer = setInterval(() => {\n if (subscribe() && pollTimer) {\n clearInterval(pollTimer);\n pollTimer = null;\n if (pollDeadline) {\n clearTimeout(pollDeadline);\n pollDeadline = null;\n }\n }\n }, POLL_INTERVAL_MS);\n\n pollDeadline = setTimeout(() => {\n if (pollTimer) {\n clearInterval(pollTimer);\n pollTimer = null;\n }\n if (!disposed && valueRef.current.status === 'connecting') {\n // Não chegamos a subscrever; degrada para `unavailable`.\n transition('unavailable');\n }\n }, POLL_TIMEOUT_MS);\n }\n\n return () => {\n disposed = true;\n if (pollTimer) clearInterval(pollTimer);\n if (pollDeadline) clearTimeout(pollDeadline);\n if (connection?.unbind) {\n for (const { event, cb } of handlers) {\n connection.unbind(event, cb);\n }\n }\n };\n }, []);\n\n return value;\n}\n","import { router } from '@inertiajs/react';\nimport { useEffect } from 'react';\n\nexport interface UseFallbackPollingOptions {\n /** Quando `false` (default), o hook não faz nada. */\n enabled?: boolean;\n /** Intervalo entre `router.reload`s. Default 30s. */\n intervalMs?: number;\n /** Lista de props parciais para `router.reload({ only })`. */\n only?: string[];\n}\n\nconst DEFAULT_INTERVAL_MS = 30_000;\n\n/**\n * Hook que dispara `router.reload({ only })` periodicamente enquanto\n * `enabled === true`. Pensado para ser ativado quando o WebSocket está\n * desconectado, dando ao usuário um fallback de \"near-realtime\".\n *\n * SSR-safe: no-op quando `window` não existe.\n */\nexport function useFallbackPolling(options: UseFallbackPollingOptions = {}): void {\n const { enabled = false, intervalMs = DEFAULT_INTERVAL_MS, only } = options;\n // Serializa `only` para estabilidade no array de dependências —\n // arrays inline criam nova referência a cada render. Em vez de listar\n // `only` (que causaria re-execução indevida), listamos apenas `onlyKey`\n // e reconstruímos a lista a partir dele dentro do efeito.\n const onlyKey = only ? only.join(',') : '';\n\n useEffect(() => {\n if (typeof window === 'undefined') return;\n if (!enabled) return;\n\n const onlyList = onlyKey ? onlyKey.split(',') : [];\n\n const interval = setInterval(() => {\n const reloadOptions: { only?: string[] } = {};\n if (onlyList.length > 0) {\n reloadOptions.only = onlyList;\n }\n router.reload(reloadOptions);\n }, intervalMs);\n\n return () => {\n clearInterval(interval);\n };\n }, [enabled, intervalMs, onlyKey]);\n}\n","import { Alert, AlertDescription } from '@arqel-dev/ui';\nimport type { ReactNode } from 'react';\nimport { type ConnectionStatus, useConnectionStatus } from './useConnectionStatus';\nimport { useFallbackPolling } from './useFallbackPolling';\n\nexport interface ConnectionStatusBannerProps {\n /**\n * Quando `true` e o status é `'disconnected'`, ativa polling Inertia\n * via `router.reload({ only: pollOnly })` enquanto durar o disconnect.\n */\n pollOnDisconnect?: boolean;\n /** Intervalo do fallback polling (default 30s). */\n pollIntervalMs?: number;\n /** Props parciais a recarregar via Inertia. */\n pollOnly?: string[];\n /**\n * Render customizado. Recebe o status atual e o número de retries desde\n * o mount. Quando fornecido, substitui o banner default — mas o componente\n * ainda retorna `null` para `connected` e `unavailable` antes de chamar\n * `renderBanner`.\n */\n renderBanner?: (status: ConnectionStatus, retryCount: number) => ReactNode;\n /** Classe CSS aplicada ao container default. */\n className?: string;\n}\n\nconst DEFAULT_MESSAGES: Record<ConnectionStatus, string> = {\n connected: '',\n connecting: 'Connecting...',\n disconnected: 'Connection lost. Reconnecting...',\n failed: 'Connection failed. Refresh page.',\n unavailable: '',\n};\n\ntype AlertVariant = 'default' | 'destructive';\n\nconst STATUS_VARIANT: Record<ConnectionStatus, AlertVariant> = {\n connected: 'default',\n connecting: 'default',\n disconnected: 'default',\n failed: 'destructive',\n unavailable: 'default',\n};\n\n// Tailwind classes layered on top of the Alert variant to convey\n// success/warning intent without extending shadcn's variant set.\nconst STATUS_TONE: Record<ConnectionStatus, string> = {\n connected: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',\n connecting: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',\n disconnected: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',\n failed: '',\n unavailable: '',\n};\n\n/**\n * Banner inline que mostra o estado da conexão WebSocket e (opcionalmente)\n * dispara fallback polling Inertia quando o canal cai.\n *\n * Render rules:\n * - `connected` → null\n * - `unavailable` → null (Echo não configurado; ignora silenciosamente)\n * - outros → `<Alert>` com variant mapeada por status\n *\n * O fallback polling só é ativado quando `pollOnDisconnect === true` e\n * `status === 'disconnected'`. Em `connecting` / `failed` não polleamos\n * (em `connecting` Pusher já está negociando; em `failed` polling não\n * resolve — usuário precisa refresh).\n */\nexport function ConnectionStatusBanner(props: ConnectionStatusBannerProps): ReactNode {\n const { pollOnDisconnect, pollIntervalMs, pollOnly, renderBanner, className } = props;\n const { status, retryCount } = useConnectionStatus();\n\n const pollingOptions: {\n enabled: boolean;\n intervalMs?: number;\n only?: string[];\n } = {\n enabled: pollOnDisconnect === true && status === 'disconnected',\n };\n if (pollIntervalMs !== undefined) pollingOptions.intervalMs = pollIntervalMs;\n if (pollOnly !== undefined) pollingOptions.only = pollOnly;\n useFallbackPolling(pollingOptions);\n\n if (status === 'connected' || status === 'unavailable') {\n return null;\n }\n\n if (renderBanner) {\n return renderBanner(status, retryCount);\n }\n\n return (\n <Alert\n variant={STATUS_VARIANT[status]}\n role=\"status\"\n aria-live=\"polite\"\n data-status={status}\n data-arqel-connection-banner=\"\"\n className={[STATUS_TONE[status], className].filter(Boolean).join(' ')}\n >\n <AlertDescription>{DEFAULT_MESSAGES[status]}</AlertDescription>\n </Alert>\n );\n}\n","import Echo from 'laravel-echo';\nimport Pusher from 'pusher-js';\nimport type { EchoConfig, EchoLike } from './types';\n\n/**\n * Bootstrapa Laravel Echo + Pusher no `window`, configurado para o broadcaster\n * Reverb (que fala o protocolo Pusher). Idempotente: chamadas subsequentes\n * com `window.Echo` já populado são silenciosamente ignoradas (warning).\n *\n * Em ambiente SSR (sem `window`), retorna sem efeito — útil para builds\n * Inertia que renderizam server-side.\n *\n * Uso típico em `resources/js/app.tsx`:\n *\n * ```ts\n * setupEcho({\n * key: import.meta.env.VITE_REVERB_APP_KEY,\n * wsHost: import.meta.env.VITE_REVERB_HOST,\n * wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,\n * wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,\n * forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',\n * });\n * ```\n */\nexport function setupEcho(config: EchoConfig): void {\n if (typeof window === 'undefined') {\n // SSR: nada a fazer. Não usamos console.error pois não é erro real.\n console.warn('[arqel-dev/realtime] setupEcho called in SSR context — skipping.');\n return;\n }\n\n const w = window as unknown as {\n Echo?: EchoLike;\n Pusher?: typeof Pusher;\n };\n\n if (w.Echo) {\n console.warn('[arqel-dev/realtime] window.Echo already initialized — skipping setupEcho.');\n return;\n }\n\n // pusher-js anexa-se ao `window.Pusher` para uso interno do laravel-echo.\n w.Pusher = Pusher;\n\n const echoOptions = {\n broadcaster: 'reverb' as const,\n ...config,\n };\n\n // O construtor do Echo aceita o objeto de opções diretamente; o tipo do\n // pacote `laravel-echo` é overloaded e não casa exatamente com nosso\n // `EchoConfig`, então fazemos cast seguro para o subset que documentamos.\n // biome-ignore lint/suspicious/noExplicitAny: laravel-echo overloads não casam com nosso EchoConfig\n const instance = new Echo(echoOptions as any);\n\n w.Echo = instance as unknown as EchoLike;\n}\n"]}
1
+ {"version":3,"sources":["../src/useConnectionStatus.ts","../src/useFallbackPolling.ts","../src/ConnectionStatusBanner.tsx","../src/setupEcho.ts"],"names":["useEffect"],"mappings":"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","file":"index.js","sourcesContent":["import { useEffect, useRef, useState } from 'react';\nimport type { EchoLike } from './types';\n\n/**\n * Estados possíveis da conexão WebSocket reportados pelo Pusher protocol\n * (Reverb fala o mesmo protocolo).\n *\n * - `connected` — handshake OK, recebendo eventos.\n * - `connecting` — tentando conectar (incluindo reconnect attempts).\n * - `disconnected` — perdeu conexão temporariamente; Pusher tentará reconectar.\n * - `failed` — falhou definitivamente; navegador/transporte incompatível.\n * - `unavailable` — `window.Echo` ausente (SSR ou pacote não bootstrapado).\n */\nexport type ConnectionStatus =\n | 'connected'\n | 'connecting'\n | 'disconnected'\n | 'failed'\n | 'unavailable';\n\nexport interface ConnectionStatusValue {\n status: ConnectionStatus;\n /** Timestamp (ms) da última transição para `connected`. */\n lastConnectedAt: number | null;\n /** Quantas vezes a conexão saiu de `connected` desde o mount. */\n retryCount: number;\n}\n\ninterface PusherConnectionLike {\n bind: (event: string, callback: (data?: unknown) => void) => void;\n unbind?: (event: string, callback?: (data?: unknown) => void) => void;\n state?: string;\n}\n\ninterface WindowWithEcho {\n Echo?: EchoLike;\n}\n\nfunction readEcho(): EchoLike | null {\n if (typeof window === 'undefined') return null;\n const w = window as unknown as WindowWithEcho;\n return w.Echo ?? null;\n}\n\nfunction readConnection(echo: EchoLike | null): PusherConnectionLike | null {\n // biome-ignore lint/suspicious/noExplicitAny: pusher-js connection é genuinamente untyped no shape público\n const connection = (echo?.connector as any)?.pusher?.connection;\n if (!connection || typeof connection.bind !== 'function') return null;\n return connection as PusherConnectionLike;\n}\n\nconst POLL_INTERVAL_MS = 250;\nconst POLL_TIMEOUT_MS = 5000;\n\n/**\n * Hook React que expõe o status atual da conexão WebSocket.\n *\n * SSR-safe: sem `window` ou sem `window.Echo`, retorna `'unavailable'`\n * permanente. Quando `window.Echo` aparece tarde (boot async), o hook\n * faz polling curto (≤5s) para se inscrever assim que disponível.\n */\nexport function useConnectionStatus(): ConnectionStatusValue {\n const [value, setValue] = useState<ConnectionStatusValue>(() => {\n if (typeof window === 'undefined') {\n return { status: 'unavailable', lastConnectedAt: null, retryCount: 0 };\n }\n const echo = readEcho();\n const connection = readConnection(echo);\n const initialState = connection?.state ?? 'connecting';\n return {\n status: initialState === 'connected' ? 'connected' : 'connecting',\n lastConnectedAt: initialState === 'connected' ? Date.now() : null,\n retryCount: 0,\n };\n });\n\n const valueRef = useRef(value);\n valueRef.current = value;\n\n useEffect(() => {\n if (typeof window === 'undefined') return;\n\n let disposed = false;\n let pollTimer: ReturnType<typeof setInterval> | null = null;\n let pollDeadline: ReturnType<typeof setTimeout> | null = null;\n let connection: PusherConnectionLike | null = null;\n\n const handlers: Array<{ event: string; cb: (data?: unknown) => void }> = [];\n\n const transition = (next: ConnectionStatus) => {\n setValue((prev) => {\n // Conta retry quando saímos de `connected` para qualquer coisa não-conectada.\n const wasConnected = prev.status === 'connected';\n const stillNotConnected = next !== 'connected';\n const incrementRetry = wasConnected && stillNotConnected;\n return {\n status: next,\n lastConnectedAt: next === 'connected' ? Date.now() : prev.lastConnectedAt,\n retryCount: incrementRetry ? prev.retryCount + 1 : prev.retryCount,\n };\n });\n };\n\n const subscribe = () => {\n const echo = readEcho();\n const conn = readConnection(echo);\n if (!conn) return false;\n\n connection = conn;\n\n const events: ConnectionStatus[] = [\n 'connected',\n 'connecting',\n 'disconnected',\n 'failed',\n 'unavailable',\n ];\n\n for (const event of events) {\n const cb = () => {\n if (disposed) return;\n // `unavailable` do Pusher significa \"transporte indisponível\" — mas\n // mantemos o nosso `'unavailable'` para \"Echo ausente\". Mapeamos o\n // evento Pusher unavailable para o estado homônimo (mesma semântica\n // de UX: nada a fazer).\n transition(event);\n };\n conn.bind(event, cb);\n handlers.push({ event, cb });\n }\n\n // Caso já estejamos conectados quando subscrevemos, normaliza o estado.\n if (conn.state === 'connected' && valueRef.current.status !== 'connected') {\n transition('connected');\n }\n\n return true;\n };\n\n if (!subscribe()) {\n // Echo ainda não disponível — polling curto até aparecer.\n pollTimer = setInterval(() => {\n if (subscribe() && pollTimer) {\n clearInterval(pollTimer);\n pollTimer = null;\n if (pollDeadline) {\n clearTimeout(pollDeadline);\n pollDeadline = null;\n }\n }\n }, POLL_INTERVAL_MS);\n\n pollDeadline = setTimeout(() => {\n if (pollTimer) {\n clearInterval(pollTimer);\n pollTimer = null;\n }\n if (!disposed && valueRef.current.status === 'connecting') {\n // Não chegamos a subscrever; degrada para `unavailable`.\n transition('unavailable');\n }\n }, POLL_TIMEOUT_MS);\n }\n\n return () => {\n disposed = true;\n if (pollTimer) clearInterval(pollTimer);\n if (pollDeadline) clearTimeout(pollDeadline);\n if (connection?.unbind) {\n for (const { event, cb } of handlers) {\n connection.unbind(event, cb);\n }\n }\n };\n }, []);\n\n return value;\n}\n","import { router } from '@inertiajs/react';\nimport { useEffect } from 'react';\n\nexport interface UseFallbackPollingOptions {\n /** Quando `false` (default), o hook não faz nada. */\n enabled?: boolean;\n /** Intervalo entre `router.reload`s. Default 30s. */\n intervalMs?: number;\n /** Lista de props parciais para `router.reload({ only })`. */\n only?: string[];\n}\n\nconst DEFAULT_INTERVAL_MS = 30_000;\n\n/**\n * Hook que dispara `router.reload({ only })` periodicamente enquanto\n * `enabled === true`. Pensado para ser ativado quando o WebSocket está\n * desconectado, dando ao usuário um fallback de \"near-realtime\".\n *\n * SSR-safe: no-op quando `window` não existe.\n */\nexport function useFallbackPolling(options: UseFallbackPollingOptions = {}): void {\n const { enabled = false, intervalMs = DEFAULT_INTERVAL_MS, only } = options;\n // Serializa `only` para estabilidade no array de dependências —\n // arrays inline criam nova referência a cada render. Em vez de listar\n // `only` (que causaria re-execução indevida), listamos apenas `onlyKey`\n // e reconstruímos a lista a partir dele dentro do efeito.\n const onlyKey = only ? only.join(',') : '';\n\n useEffect(() => {\n if (typeof window === 'undefined') return;\n if (!enabled) return;\n\n const onlyList = onlyKey ? onlyKey.split(',') : [];\n\n const interval = setInterval(() => {\n const reloadOptions: { only?: string[] } = {};\n if (onlyList.length > 0) {\n reloadOptions.only = onlyList;\n }\n router.reload(reloadOptions);\n }, intervalMs);\n\n return () => {\n clearInterval(interval);\n };\n }, [enabled, intervalMs, onlyKey]);\n}\n","import { useArqelTranslations } from '@arqel-dev/react/utils';\nimport { Alert, AlertDescription } from '@arqel-dev/ui';\nimport type { ReactNode } from 'react';\nimport { type ConnectionStatus, useConnectionStatus } from './useConnectionStatus';\nimport { useFallbackPolling } from './useFallbackPolling';\n\nexport interface ConnectionStatusBannerProps {\n /**\n * Quando `true` e o status é `'disconnected'`, ativa polling Inertia\n * via `router.reload({ only: pollOnly })` enquanto durar o disconnect.\n */\n pollOnDisconnect?: boolean;\n /** Intervalo do fallback polling (default 30s). */\n pollIntervalMs?: number;\n /** Props parciais a recarregar via Inertia. */\n pollOnly?: string[];\n /**\n * Render customizado. Recebe o status atual e o número de retries desde\n * o mount. Quando fornecido, substitui o banner default — mas o componente\n * ainda retorna `null` para `connected` e `unavailable` antes de chamar\n * `renderBanner`.\n */\n renderBanner?: (status: ConnectionStatus, retryCount: number) => ReactNode;\n /** Classe CSS aplicada ao container default. */\n className?: string;\n}\n\n/**\n * Maps each connection state to its translation key and the English literal\n * used as a fallback when the key is absent from the shared dictionary (a\n * non-Arqel page or a translation gap). Resolved through\n * `useArqelTranslations()` at render time so the active panel locale applies\n * to this `role=status` / `aria-live` banner.\n */\nconst STATUS_MESSAGE: Record<ConnectionStatus, { key: string; fallback: string }> = {\n connected: { key: '', fallback: '' },\n connecting: { key: 'arqel.realtime.connecting', fallback: 'Connecting...' },\n disconnected: {\n key: 'arqel.realtime.disconnected',\n fallback: 'Connection lost. Reconnecting...',\n },\n failed: { key: 'arqel.realtime.failed', fallback: 'Connection failed. Refresh page.' },\n unavailable: { key: '', fallback: '' },\n};\n\ntype AlertVariant = 'default' | 'destructive';\n\nconst STATUS_VARIANT: Record<ConnectionStatus, AlertVariant> = {\n connected: 'default',\n connecting: 'default',\n disconnected: 'default',\n failed: 'destructive',\n unavailable: 'default',\n};\n\n// Tailwind classes layered on top of the Alert variant to convey\n// success/warning intent without extending shadcn's variant set.\nconst STATUS_TONE: Record<ConnectionStatus, string> = {\n connected: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',\n connecting: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',\n disconnected: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',\n failed: '',\n unavailable: '',\n};\n\n/**\n * Banner inline que mostra o estado da conexão WebSocket e (opcionalmente)\n * dispara fallback polling Inertia quando o canal cai.\n *\n * Render rules:\n * - `connected` → null\n * - `unavailable` → null (Echo não configurado; ignora silenciosamente)\n * - outros → `<Alert>` com variant mapeada por status\n *\n * O fallback polling só é ativado quando `pollOnDisconnect === true` e\n * `status === 'disconnected'`. Em `connecting` / `failed` não polleamos\n * (em `connecting` Pusher já está negociando; em `failed` polling não\n * resolve — usuário precisa refresh).\n */\nexport function ConnectionStatusBanner(props: ConnectionStatusBannerProps): ReactNode {\n const { pollOnDisconnect, pollIntervalMs, pollOnly, renderBanner, className } = props;\n const { status, retryCount } = useConnectionStatus();\n const t = useArqelTranslations();\n\n const pollingOptions: {\n enabled: boolean;\n intervalMs?: number;\n only?: string[];\n } = {\n enabled: pollOnDisconnect === true && status === 'disconnected',\n };\n if (pollIntervalMs !== undefined) pollingOptions.intervalMs = pollIntervalMs;\n if (pollOnly !== undefined) pollingOptions.only = pollOnly;\n useFallbackPolling(pollingOptions);\n\n if (status === 'connected' || status === 'unavailable') {\n return null;\n }\n\n if (renderBanner) {\n return renderBanner(status, retryCount);\n }\n\n return (\n <Alert\n variant={STATUS_VARIANT[status]}\n role=\"status\"\n aria-live=\"polite\"\n data-status={status}\n data-arqel-connection-banner=\"\"\n className={[STATUS_TONE[status], className].filter(Boolean).join(' ')}\n >\n <AlertDescription>\n {t(STATUS_MESSAGE[status].key, STATUS_MESSAGE[status].fallback)}\n </AlertDescription>\n </Alert>\n );\n}\n","import Echo from 'laravel-echo';\nimport Pusher from 'pusher-js';\nimport type { EchoConfig, EchoLike } from './types';\n\n/**\n * Bootstrapa Laravel Echo + Pusher no `window`, configurado para o broadcaster\n * Reverb (que fala o protocolo Pusher). Idempotente: chamadas subsequentes\n * com `window.Echo` já populado são silenciosamente ignoradas (warning).\n *\n * Em ambiente SSR (sem `window`), retorna sem efeito — útil para builds\n * Inertia que renderizam server-side.\n *\n * Uso típico em `resources/js/app.tsx`:\n *\n * ```ts\n * setupEcho({\n * key: import.meta.env.VITE_REVERB_APP_KEY,\n * wsHost: import.meta.env.VITE_REVERB_HOST,\n * wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,\n * wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,\n * forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',\n * });\n * ```\n */\nexport function setupEcho(config: EchoConfig): void {\n if (typeof window === 'undefined') {\n // SSR: nada a fazer. Não usamos console.error pois não é erro real.\n console.warn('[arqel-dev/realtime] setupEcho called in SSR context — skipping.');\n return;\n }\n\n const w = window as unknown as {\n Echo?: EchoLike;\n Pusher?: typeof Pusher;\n };\n\n if (w.Echo) {\n console.warn('[arqel-dev/realtime] window.Echo already initialized — skipping setupEcho.');\n return;\n }\n\n // pusher-js anexa-se ao `window.Pusher` para uso interno do laravel-echo.\n w.Pusher = Pusher;\n\n const echoOptions = {\n broadcaster: 'reverb' as const,\n ...config,\n };\n\n // O construtor do Echo aceita o objeto de opções diretamente; o tipo do\n // pacote `laravel-echo` é overloaded e não casa exatamente com nosso\n // `EchoConfig`, então fazemos cast seguro para o subset que documentamos.\n // biome-ignore lint/suspicious/noExplicitAny: laravel-echo overloads não casam com nosso EchoConfig\n const instance = new Echo(echoOptions as any);\n\n w.Echo = instance as unknown as EchoLike;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arqel-dev/realtime",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Laravel Echo + Reverb setup helper para Arqel — bootstrap WebSocket realtime pipeline.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://arqel.dev",
@@ -38,7 +38,8 @@
38
38
  "peerDependencies": {
39
39
  "@inertiajs/react": "^3.0.0",
40
40
  "react": "^19.2.0",
41
- "@arqel-dev/ui": "0.15.0"
41
+ "@arqel-dev/react": "0.15.1",
42
+ "@arqel-dev/ui": "0.15.1"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@inertiajs/react": "^3.0.0",
@@ -51,7 +52,8 @@
51
52
  "tsup": "^8.5.0",
52
53
  "typescript": "^5.6.3",
53
54
  "vitest": "^4.1.0",
54
- "@arqel-dev/ui": "0.15.0"
55
+ "@arqel-dev/react": "0.15.1",
56
+ "@arqel-dev/ui": "0.15.1"
55
57
  },
56
58
  "publishConfig": {
57
59
  "access": "public"