@arqel-dev/realtime 0.8.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arqel Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @arqel-dev/realtime
2
+
3
+ Helper de bootstrap do Laravel Echo para apps Arqel — configura WebSockets
4
+ contra um servidor Laravel Reverb.
5
+
6
+ ## Instalação
7
+
8
+ ```bash
9
+ pnpm add @arqel-dev/realtime
10
+ ```
11
+
12
+ `laravel-echo` e `pusher-js` já vêm como dependências diretas.
13
+
14
+ ## Uso
15
+
16
+ ```ts
17
+ // resources/js/app.tsx
18
+ import { createArqelApp } from '@arqel-dev/react';
19
+ import { setupEcho } from '@arqel-dev/realtime';
20
+
21
+ setupEcho({
22
+ key: import.meta.env.VITE_REVERB_APP_KEY,
23
+ wsHost: import.meta.env.VITE_REVERB_HOST,
24
+ wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
25
+ wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
26
+ forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
27
+ });
28
+
29
+ createArqelApp();
30
+ ```
31
+
32
+ Após o `setupEcho`, hooks de `@arqel-dev/hooks` (`useResourceUpdates`,
33
+ `useResourcePresence`, etc.) usam o `window.Echo` automaticamente.
34
+
35
+ ## Características
36
+
37
+ - **Idempotente** — pode ser chamado múltiplas vezes (útil para HMR).
38
+ - **SSR-safe** — sem `window`, retorna no-op com warning.
39
+ - **Tipos exportados** — `EchoLike`, `EchoChannelLike`,
40
+ `PresenceChannelLike` para uso em hooks customizados.
41
+
42
+ ## Documentação
43
+
44
+ Veja `SKILL.md` neste pacote e o ticket RT-008 em
45
+ `PLANNING/10-fase-3-avancadas.md`.
46
+
47
+ ## Licença
48
+
49
+ MIT.
package/SKILL.md ADDED
@@ -0,0 +1,135 @@
1
+ # SKILL.md — arqel-dev/realtime (JS)
2
+
3
+ ## Purpose
4
+
5
+ Bootstrap do pipeline realtime do Arqel. Expõe `setupEcho(config)` —
6
+ helper que instancia o Laravel Echo apontando para o broadcaster
7
+ **Reverb** e anexa Pusher no `window`. É a única peça que conhece os
8
+ pacotes `laravel-echo` e `pusher-js`; consumidores (apps Inertia, hooks
9
+ em `@arqel-dev/hooks`) lidam apenas com o tipo `EchoLike`.
10
+
11
+ O pacote **não** re-exporta hooks do `@arqel-dev/hooks`. Mantemos separado
12
+ para code-splitting: quem só precisa de tipos não baixa Echo.
13
+
14
+ ## Key Contracts
15
+
16
+ ```ts
17
+ import { setupEcho } from '@arqel-dev/realtime';
18
+
19
+ setupEcho({
20
+ key: import.meta.env.VITE_REVERB_APP_KEY,
21
+ wsHost: import.meta.env.VITE_REVERB_HOST,
22
+ wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
23
+ wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
24
+ forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
25
+ });
26
+ ```
27
+
28
+ Tipos públicos exportados:
29
+
30
+ - `EchoConfig` — shape aceito por `setupEcho`.
31
+ - `EchoLike` — subset da instância Echo consumida pelos hooks.
32
+ - `EchoChannelLike`, `PresenceChannelLike`, `PresenceMember` — formas
33
+ de canal que hooks (`useResourceUpdates`, `useResourcePresence`,
34
+ `useActionProgress`, `useWidgetRealtime`) podem assumir.
35
+ - `EchoEventListener`, `EchoConnectorLike` — auxiliares.
36
+
37
+ ## Conventions
38
+
39
+ - **Idempotente**: chamar `setupEcho` duas vezes não recria a instância
40
+ e emite `console.warn`. Útil para HMR.
41
+ - **SSR-safe**: em ambientes sem `window` (Node SSR), retorna sem efeito
42
+ com warning — não lança.
43
+ - **Broadcaster fixo em `'reverb'`**: o helper foi desenhado para
44
+ Reverb (que fala protocolo Pusher). Para usar Pusher hospedado direto,
45
+ consumidor pode instanciar Echo manualmente — `@arqel-dev/realtime` é
46
+ intencionalmente opinionated.
47
+ - **Sem hooks aqui**: hooks moram em `@arqel-dev/hooks` para permitir que o
48
+ bundle do app só importe `setupEcho` se quiser.
49
+ - TypeScript strict, sem `any` (apenas asserts pontuais nos pontos onde
50
+ o tipo do `laravel-echo` é overloaded e não casa com `EchoConfig`).
51
+ - Lint: Biome (`pnpm lint`); testes: Vitest; build: tsup.
52
+
53
+ ## Examples
54
+
55
+ ```tsx
56
+ // resources/js/app.tsx
57
+ import { createArqelApp } from '@arqel-dev/react';
58
+ import { setupEcho } from '@arqel-dev/realtime';
59
+
60
+ setupEcho({
61
+ key: import.meta.env.VITE_REVERB_APP_KEY,
62
+ wsHost: import.meta.env.VITE_REVERB_HOST,
63
+ wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
64
+ wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
65
+ forceTLS: import.meta.env.VITE_REVERB_SCHEME === 'https',
66
+ });
67
+
68
+ createArqelApp();
69
+ ```
70
+
71
+ Consumir o `window.Echo` em um hook custom usando os tipos publicados:
72
+
73
+ ```ts
74
+ import type { EchoLike } from '@arqel-dev/realtime';
75
+
76
+ function getEcho(): EchoLike | null {
77
+ if (typeof window === 'undefined') return null;
78
+ return (window as unknown as { Echo?: EchoLike }).Echo ?? null;
79
+ }
80
+ ```
81
+
82
+ ## Anti-patterns
83
+
84
+ - Chamar `setupEcho` dentro de componente React — deve ser invocado
85
+ uma única vez no bootstrap (`app.tsx`).
86
+ - Importar `laravel-echo` ou `pusher-js` diretamente em código de app —
87
+ use o helper. Mantém upgrade path centralizado.
88
+ - Re-exportar hooks aqui — quebra code-splitting; hooks ficam em
89
+ `@arqel-dev/hooks`.
90
+ - Trocar o `broadcaster` para algo diferente de `'reverb'` neste helper —
91
+ se precisar Pusher direto, instancie Echo manualmente.
92
+
93
+ ## Connection resilience (RT-010)
94
+
95
+ Além do `setupEcho`, o pacote expõe três utilitários para UX graceful
96
+ quando o WebSocket cai:
97
+
98
+ - `useConnectionStatus()` — hook que devolve
99
+ `{ status, lastConnectedAt, retryCount }` lendo os eventos do
100
+ `window.Echo.connector.pusher.connection`. SSR-safe (degrada para
101
+ `'unavailable'` quando `window.Echo` não aparece em até ~5s).
102
+ - `useFallbackPolling({ enabled, intervalMs, only })` — quando
103
+ `enabled` é `true`, dispara `router.reload({ only })` periodicamente.
104
+ Útil para acionar durante disconnect e manter "near-realtime".
105
+ - `<ConnectionStatusBanner />` — componente que combina ambos. Renderiza
106
+ `null` para `connected` e `unavailable`; em `disconnected` /
107
+ `connecting` / `failed` mostra um banner inline com `role="status"`
108
+ e `aria-live="polite"`.
109
+
110
+ Exemplo típico no layout do panel:
111
+
112
+ ```tsx
113
+ import { ConnectionStatusBanner } from '@arqel-dev/realtime';
114
+
115
+ export function PanelLayout({ children }: { children: ReactNode }) {
116
+ return (
117
+ <>
118
+ <ConnectionStatusBanner pollOnDisconnect pollOnly={['records']} />
119
+ {children}
120
+ </>
121
+ );
122
+ }
123
+ ```
124
+
125
+ `pollOnDisconnect` ativa o fallback Inertia apenas durante o estado
126
+ `'disconnected'` (em `'connecting'` o Pusher já está negociando; em
127
+ `'failed'` polling não resolve — usuário precisa de refresh).
128
+
129
+ ## Related
130
+
131
+ - Tickets: `PLANNING/10-fase-3-avancadas.md` → RT-008, RT-010.
132
+ - Hooks consumidores: `packages-js/hooks/src/useResource*` e
133
+ `useAction*`.
134
+ - PHP backend: `packages/realtime` (RT-001 — broadcasting service
135
+ provider + Reverb config).
@@ -0,0 +1,192 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ /**
4
+ * Estados possíveis da conexão WebSocket reportados pelo Pusher protocol
5
+ * (Reverb fala o mesmo protocolo).
6
+ *
7
+ * - `connected` — handshake OK, recebendo eventos.
8
+ * - `connecting` — tentando conectar (incluindo reconnect attempts).
9
+ * - `disconnected` — perdeu conexão temporariamente; Pusher tentará reconectar.
10
+ * - `failed` — falhou definitivamente; navegador/transporte incompatível.
11
+ * - `unavailable` — `window.Echo` ausente (SSR ou pacote não bootstrapado).
12
+ */
13
+ type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'failed' | 'unavailable';
14
+ interface ConnectionStatusValue {
15
+ status: ConnectionStatus;
16
+ /** Timestamp (ms) da última transição para `connected`. */
17
+ lastConnectedAt: number | null;
18
+ /** Quantas vezes a conexão saiu de `connected` desde o mount. */
19
+ retryCount: number;
20
+ }
21
+ /**
22
+ * Hook React que expõe o status atual da conexão WebSocket.
23
+ *
24
+ * SSR-safe: sem `window` ou sem `window.Echo`, retorna `'unavailable'`
25
+ * permanente. Quando `window.Echo` aparece tarde (boot async), o hook
26
+ * faz polling curto (≤5s) para se inscrever assim que disponível.
27
+ */
28
+ declare function useConnectionStatus(): ConnectionStatusValue;
29
+
30
+ interface ConnectionStatusBannerProps {
31
+ /**
32
+ * Quando `true` e o status é `'disconnected'`, ativa polling Inertia
33
+ * via `router.reload({ only: pollOnly })` enquanto durar o disconnect.
34
+ */
35
+ pollOnDisconnect?: boolean;
36
+ /** Intervalo do fallback polling (default 30s). */
37
+ pollIntervalMs?: number;
38
+ /** Props parciais a recarregar via Inertia. */
39
+ pollOnly?: string[];
40
+ /**
41
+ * Render customizado. Recebe o status atual e o número de retries desde
42
+ * o mount. Quando fornecido, substitui o banner default — mas o componente
43
+ * ainda retorna `null` para `connected` e `unavailable` antes de chamar
44
+ * `renderBanner`.
45
+ */
46
+ renderBanner?: (status: ConnectionStatus, retryCount: number) => ReactNode;
47
+ /** Classe CSS aplicada ao container default. */
48
+ className?: string;
49
+ }
50
+ /**
51
+ * Banner inline que mostra o estado da conexão WebSocket e (opcionalmente)
52
+ * dispara fallback polling Inertia quando o canal cai.
53
+ *
54
+ * Render rules:
55
+ * - `connected` → null
56
+ * - `unavailable` → null (Echo não configurado; ignora silenciosamente)
57
+ * - outros → `<Alert>` com variant mapeada por status
58
+ *
59
+ * O fallback polling só é ativado quando `pollOnDisconnect === true` e
60
+ * `status === 'disconnected'`. Em `connecting` / `failed` não polleamos
61
+ * (em `connecting` Pusher já está negociando; em `failed` polling não
62
+ * resolve — usuário precisa refresh).
63
+ */
64
+ declare function ConnectionStatusBanner(props: ConnectionStatusBannerProps): ReactNode;
65
+
66
+ /**
67
+ * Tipos públicos do Echo expostos para consumidores do `@arqel-dev/realtime`.
68
+ *
69
+ * Esses tipos são intencionalmente shape-based (não estruturais aos pacotes
70
+ * `laravel-echo` / `pusher-js`) para evitar acoplar a API pública do Arqel
71
+ * a versões internas. Hooks em `@arqel-dev/hooks` podem importar esses tipos
72
+ * sem precisar redeclarar `Window`.
73
+ */
74
+ /**
75
+ * Listener handler usado em todas as subscrições de canal.
76
+ */
77
+ type EchoEventListener = (payload: unknown) => void;
78
+ /**
79
+ * Canal público / privado básico — o suficiente para `useResourceUpdates`,
80
+ * `useActionProgress`, `useWidgetRealtime`.
81
+ */
82
+ interface EchoChannelLike {
83
+ listen: (event: string, callback: EchoEventListener) => EchoChannelLike;
84
+ stopListening: (event: string) => EchoChannelLike;
85
+ notification?: (callback: EchoEventListener) => EchoChannelLike;
86
+ }
87
+ /**
88
+ * Membro genérico de canal de presença.
89
+ */
90
+ interface PresenceMember {
91
+ id: string | number;
92
+ info?: Record<string, unknown>;
93
+ }
94
+ /**
95
+ * Canal de presença — superset do canal privado.
96
+ */
97
+ interface PresenceChannelLike extends EchoChannelLike {
98
+ here: (callback: (members: PresenceMember[]) => void) => PresenceChannelLike;
99
+ joining: (callback: (member: PresenceMember) => void) => PresenceChannelLike;
100
+ leaving: (callback: (member: PresenceMember) => void) => PresenceChannelLike;
101
+ }
102
+ /**
103
+ * Conector low-level — usado para hooks de auto-reconnect.
104
+ */
105
+ interface EchoConnectorLike {
106
+ pusher?: {
107
+ connection?: {
108
+ bind: (event: string, callback: EchoEventListener) => void;
109
+ unbind?: (event: string, callback?: EchoEventListener) => void;
110
+ state?: string;
111
+ };
112
+ };
113
+ }
114
+ /**
115
+ * Subset minimo da instância do Laravel Echo que o Arqel consome.
116
+ *
117
+ * Hooks devem aceitar `EchoLike` e nunca depender de tipos internos do
118
+ * pacote `laravel-echo` — assim consumidores podem fornecer mocks ou
119
+ * implementações alternativas (por exemplo Soketi).
120
+ */
121
+ interface EchoLike {
122
+ channel: (name: string) => EchoChannelLike;
123
+ private: (name: string) => EchoChannelLike;
124
+ join: (name: string) => PresenceChannelLike;
125
+ leave: (name: string) => void;
126
+ leaveChannel: (name: string) => void;
127
+ disconnect: () => void;
128
+ connector?: EchoConnectorLike;
129
+ }
130
+ /**
131
+ * Configuração aceita por `setupEcho`. Espelha a forma do construtor do
132
+ * Laravel Echo focada no broadcaster Reverb (que usa o protocolo Pusher).
133
+ */
134
+ interface EchoConfig {
135
+ /** App key da Reverb / Pusher. */
136
+ key: string;
137
+ /** Host WebSocket (default `window.location.hostname`). */
138
+ wsHost?: string;
139
+ /** Porta WebSocket plain. */
140
+ wsPort?: number | string;
141
+ /** Porta WebSocket TLS. */
142
+ wssPort?: number | string;
143
+ /** Força TLS na conexão. */
144
+ forceTLS?: boolean;
145
+ /** Alias legacy de `forceTLS`. */
146
+ encrypted?: boolean;
147
+ /** Cluster Pusher (não usado pelo Reverb, mas aceito). */
148
+ cluster?: string;
149
+ /** Endpoint Laravel para auth de canais privados/presence. */
150
+ authEndpoint?: string;
151
+ }
152
+
153
+ /**
154
+ * Bootstrapa Laravel Echo + Pusher no `window`, configurado para o broadcaster
155
+ * Reverb (que fala o protocolo Pusher). Idempotente: chamadas subsequentes
156
+ * com `window.Echo` já populado são silenciosamente ignoradas (warning).
157
+ *
158
+ * Em ambiente SSR (sem `window`), retorna sem efeito — útil para builds
159
+ * Inertia que renderizam server-side.
160
+ *
161
+ * Uso típico em `resources/js/app.tsx`:
162
+ *
163
+ * ```ts
164
+ * setupEcho({
165
+ * key: import.meta.env.VITE_REVERB_APP_KEY,
166
+ * wsHost: import.meta.env.VITE_REVERB_HOST,
167
+ * wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
168
+ * wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
169
+ * forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
170
+ * });
171
+ * ```
172
+ */
173
+ declare function setupEcho(config: EchoConfig): void;
174
+
175
+ interface UseFallbackPollingOptions {
176
+ /** Quando `false` (default), o hook não faz nada. */
177
+ enabled?: boolean;
178
+ /** Intervalo entre `router.reload`s. Default 30s. */
179
+ intervalMs?: number;
180
+ /** Lista de props parciais para `router.reload({ only })`. */
181
+ only?: string[];
182
+ }
183
+ /**
184
+ * Hook que dispara `router.reload({ only })` periodicamente enquanto
185
+ * `enabled === true`. Pensado para ser ativado quando o WebSocket está
186
+ * desconectado, dando ao usuário um fallback de "near-realtime".
187
+ *
188
+ * SSR-safe: no-op quando `window` não existe.
189
+ */
190
+ declare function useFallbackPolling(options?: UseFallbackPollingOptions): void;
191
+
192
+ export { type ConnectionStatus, ConnectionStatusBanner, type ConnectionStatusBannerProps, type ConnectionStatusValue, type EchoChannelLike, type EchoConfig, type EchoConnectorLike, type EchoEventListener, type EchoLike, type PresenceChannelLike, type PresenceMember, type UseFallbackPollingOptions, setupEcho, useConnectionStatus, useFallbackPolling };
package/dist/index.js ADDED
@@ -0,0 +1,205 @@
1
+ import { Alert, AlertDescription } from '@arqel-dev/ui';
2
+ import { useState, useRef, useEffect } from 'react';
3
+ import { router } from '@inertiajs/react';
4
+ import { jsx } from 'react/jsx-runtime';
5
+ import Echo from 'laravel-echo';
6
+ import Pusher from 'pusher-js';
7
+
8
+ // src/ConnectionStatusBanner.tsx
9
+ function readEcho() {
10
+ if (typeof window === "undefined") return null;
11
+ const w = window;
12
+ return w.Echo ?? null;
13
+ }
14
+ function readConnection(echo) {
15
+ const connection = echo?.connector?.pusher?.connection;
16
+ if (!connection || typeof connection.bind !== "function") return null;
17
+ return connection;
18
+ }
19
+ var POLL_INTERVAL_MS = 250;
20
+ var POLL_TIMEOUT_MS = 5e3;
21
+ function useConnectionStatus() {
22
+ const [value, setValue] = useState(() => {
23
+ if (typeof window === "undefined") {
24
+ return { status: "unavailable", lastConnectedAt: null, retryCount: 0 };
25
+ }
26
+ const echo = readEcho();
27
+ const connection = readConnection(echo);
28
+ const initialState = connection?.state ?? "connecting";
29
+ return {
30
+ status: initialState === "connected" ? "connected" : "connecting",
31
+ lastConnectedAt: initialState === "connected" ? Date.now() : null,
32
+ retryCount: 0
33
+ };
34
+ });
35
+ const valueRef = useRef(value);
36
+ valueRef.current = value;
37
+ useEffect(() => {
38
+ if (typeof window === "undefined") return;
39
+ let disposed = false;
40
+ let pollTimer = null;
41
+ let pollDeadline = null;
42
+ let connection = null;
43
+ const handlers = [];
44
+ const transition = (next) => {
45
+ setValue((prev) => {
46
+ const wasConnected = prev.status === "connected";
47
+ const stillNotConnected = next !== "connected";
48
+ const incrementRetry = wasConnected && stillNotConnected;
49
+ return {
50
+ status: next,
51
+ lastConnectedAt: next === "connected" ? Date.now() : prev.lastConnectedAt,
52
+ retryCount: incrementRetry ? prev.retryCount + 1 : prev.retryCount
53
+ };
54
+ });
55
+ };
56
+ const subscribe = () => {
57
+ const echo = readEcho();
58
+ const conn = readConnection(echo);
59
+ if (!conn) return false;
60
+ connection = conn;
61
+ const events = [
62
+ "connected",
63
+ "connecting",
64
+ "disconnected",
65
+ "failed",
66
+ "unavailable"
67
+ ];
68
+ for (const event of events) {
69
+ const cb = () => {
70
+ if (disposed) return;
71
+ transition(event);
72
+ };
73
+ conn.bind(event, cb);
74
+ handlers.push({ event, cb });
75
+ }
76
+ if (conn.state === "connected" && valueRef.current.status !== "connected") {
77
+ transition("connected");
78
+ }
79
+ return true;
80
+ };
81
+ if (!subscribe()) {
82
+ pollTimer = setInterval(() => {
83
+ if (subscribe() && pollTimer) {
84
+ clearInterval(pollTimer);
85
+ pollTimer = null;
86
+ if (pollDeadline) {
87
+ clearTimeout(pollDeadline);
88
+ pollDeadline = null;
89
+ }
90
+ }
91
+ }, POLL_INTERVAL_MS);
92
+ pollDeadline = setTimeout(() => {
93
+ if (pollTimer) {
94
+ clearInterval(pollTimer);
95
+ pollTimer = null;
96
+ }
97
+ if (!disposed && valueRef.current.status === "connecting") {
98
+ transition("unavailable");
99
+ }
100
+ }, POLL_TIMEOUT_MS);
101
+ }
102
+ return () => {
103
+ disposed = true;
104
+ if (pollTimer) clearInterval(pollTimer);
105
+ if (pollDeadline) clearTimeout(pollDeadline);
106
+ if (connection?.unbind) {
107
+ for (const { event, cb } of handlers) {
108
+ connection.unbind(event, cb);
109
+ }
110
+ }
111
+ };
112
+ }, []);
113
+ return value;
114
+ }
115
+ var DEFAULT_INTERVAL_MS = 3e4;
116
+ function useFallbackPolling(options = {}) {
117
+ const { enabled = false, intervalMs = DEFAULT_INTERVAL_MS, only } = options;
118
+ const onlyKey = only ? only.join(",") : "";
119
+ useEffect(() => {
120
+ if (typeof window === "undefined") return;
121
+ if (!enabled) return;
122
+ const onlyList = onlyKey ? onlyKey.split(",") : [];
123
+ const interval = setInterval(() => {
124
+ const reloadOptions = {};
125
+ if (onlyList.length > 0) {
126
+ reloadOptions.only = onlyList;
127
+ }
128
+ router.reload(reloadOptions);
129
+ }, intervalMs);
130
+ return () => {
131
+ clearInterval(interval);
132
+ };
133
+ }, [enabled, intervalMs, onlyKey]);
134
+ }
135
+ var DEFAULT_MESSAGES = {
136
+ connected: "",
137
+ connecting: "Connecting...",
138
+ disconnected: "Connection lost. Reconnecting...",
139
+ failed: "Connection failed. Refresh page.",
140
+ unavailable: ""
141
+ };
142
+ var STATUS_VARIANT = {
143
+ connected: "default",
144
+ connecting: "default",
145
+ disconnected: "default",
146
+ failed: "destructive",
147
+ unavailable: "default"
148
+ };
149
+ var STATUS_TONE = {
150
+ connected: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
151
+ connecting: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
152
+ disconnected: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
153
+ failed: "",
154
+ unavailable: ""
155
+ };
156
+ function ConnectionStatusBanner(props) {
157
+ const { pollOnDisconnect, pollIntervalMs, pollOnly, renderBanner, className } = props;
158
+ const { status, retryCount } = useConnectionStatus();
159
+ const pollingOptions = {
160
+ enabled: pollOnDisconnect === true && status === "disconnected"
161
+ };
162
+ if (pollIntervalMs !== void 0) pollingOptions.intervalMs = pollIntervalMs;
163
+ if (pollOnly !== void 0) pollingOptions.only = pollOnly;
164
+ useFallbackPolling(pollingOptions);
165
+ if (status === "connected" || status === "unavailable") {
166
+ return null;
167
+ }
168
+ if (renderBanner) {
169
+ return renderBanner(status, retryCount);
170
+ }
171
+ return /* @__PURE__ */ jsx(
172
+ Alert,
173
+ {
174
+ variant: STATUS_VARIANT[status],
175
+ role: "status",
176
+ "aria-live": "polite",
177
+ "data-status": status,
178
+ "data-arqel-connection-banner": "",
179
+ className: [STATUS_TONE[status], className].filter(Boolean).join(" "),
180
+ children: /* @__PURE__ */ jsx(AlertDescription, { children: DEFAULT_MESSAGES[status] })
181
+ }
182
+ );
183
+ }
184
+ function setupEcho(config) {
185
+ if (typeof window === "undefined") {
186
+ console.warn("[arqel-dev/realtime] setupEcho called in SSR context \u2014 skipping.");
187
+ return;
188
+ }
189
+ const w = window;
190
+ if (w.Echo) {
191
+ console.warn("[arqel-dev/realtime] window.Echo already initialized \u2014 skipping setupEcho.");
192
+ return;
193
+ }
194
+ w.Pusher = Pusher;
195
+ const echoOptions = {
196
+ broadcaster: "reverb",
197
+ ...config
198
+ };
199
+ const instance = new Echo(echoOptions);
200
+ w.Echo = instance;
201
+ }
202
+
203
+ export { ConnectionStatusBanner, setupEcho, useConnectionStatus, useFallbackPolling };
204
+ //# sourceMappingURL=index.js.map
205
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"]}
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@arqel-dev/realtime",
3
+ "version": "0.8.0",
4
+ "description": "Laravel Echo + Reverb setup helper para Arqel — bootstrap WebSocket realtime pipeline.",
5
+ "license": "MIT",
6
+ "homepage": "https://arqel.dev",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/arqel-dev/arqel.git",
10
+ "directory": "packages-js/realtime"
11
+ },
12
+ "keywords": [
13
+ "arqel",
14
+ "laravel",
15
+ "inertia",
16
+ "react",
17
+ "realtime",
18
+ "echo",
19
+ "reverb",
20
+ "websocket"
21
+ ],
22
+ "type": "module",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "SKILL.md"
33
+ ],
34
+ "dependencies": {
35
+ "laravel-echo": "^2.0.0",
36
+ "pusher-js": "^8.4.0"
37
+ },
38
+ "peerDependencies": {
39
+ "@inertiajs/react": "^3.0.0",
40
+ "react": "^19.2.0",
41
+ "@arqel-dev/ui": "0.8.0"
42
+ },
43
+ "devDependencies": {
44
+ "@inertiajs/react": "^3.0.0",
45
+ "@testing-library/react": "^16.1.0",
46
+ "@types/react": "^19.0.0",
47
+ "@types/react-dom": "^19.0.0",
48
+ "jsdom": "^25.0.1",
49
+ "react": "^19.0.0",
50
+ "react-dom": "^19.0.0",
51
+ "tsup": "^8.5.0",
52
+ "typescript": "^5.6.3",
53
+ "vitest": "^3.0.0",
54
+ "@arqel-dev/ui": "0.8.0"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "engines": {
60
+ "node": ">=20.9.0"
61
+ },
62
+ "scripts": {
63
+ "build": "tsup",
64
+ "dev": "tsup --watch",
65
+ "typecheck": "tsc --noEmit",
66
+ "lint": "biome check --diagnostic-level=error src tests",
67
+ "format": "biome format --write src tests",
68
+ "test": "vitest run",
69
+ "test:watch": "vitest"
70
+ }
71
+ }