diff --git a/package-lock.json b/package-lock.json index 6e96d94..e8f9db6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@angular/platform-browser-dynamic": "^21.0.0", "@angular/router": "^21.0.0", "@capacitor/android": ">=6.0.0 <9.0.0", + "@capacitor/app": "^8.0.0", "@capacitor/camera": ">=6.0.0 <9.0.0", "@capacitor/core": ">=6.0.0 <9.0.0", "@capacitor/ios": ">=6.0.0 <9.0.0", @@ -3520,6 +3521,15 @@ "@capacitor/core": "^8.4.0" } }, + "node_modules/@capacitor/app": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.1.tgz", + "integrity": "sha512-xM2ZTX5jK60tFtmjsmJv+Cdj1Qsb6NcavkqmdEnCPQBeya9oKhamZJxYOnQNj1ZvcJM7sWfG6BEtSLx42pisbA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/camera": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-8.2.0.tgz", diff --git a/package.json b/package.json index 3dbbb40..9bd04f3 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@angular/platform-browser-dynamic": "^21.0.0", "@angular/router": "^21.0.0", "@capacitor/android": ">=6.0.0 <9.0.0", + "@capacitor/app": "^8.0.0", "@capacitor/camera": ">=6.0.0 <9.0.0", "@capacitor/core": ">=6.0.0 <9.0.0", "@capacitor/ios": ">=6.0.0 <9.0.0", diff --git a/projects/kit/README.md b/projects/kit/README.md index 5b9dbd3..b6d5ff9 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -6,6 +6,7 @@ A small ergonomic kit for Ionic Angular applications. It provides: - **KitOverlayController** — a unified presenter for Ionic Modal, Toast, and Alert - **Auth guards** — functional `CanActivateFn` guards for a 4-state auth model - **HTTP interceptor** — a fleet-canonical auth + retry + error-hook interceptor +- **KitRealtimeConnection** — foreground/network-aware Hibernation WebSocket reconnect and resync - **KitAuthInputDirective** — sign-in email remember/prefill + iOS autofill workaround for `ion-input` - **kitClearStoragePreservingKeys** — `clear()` that restores selected keys (`KIT_LAST_AUTH_EMAIL_KEY`, `KIT_THEME_STORAGE_KEY`, …) @@ -31,6 +32,7 @@ Kit shares the repo `v*` release line with the other libraries (see root README | `@ionic/angular` | `^8.0.0` | | `@ionic/storage-angular` | `^4.0.0` | | `@capacitor/core` | `>=6.0.0 <9.0.0` | +| `@capacitor/app` | `>=6.0.0 <9.0.0` | | `@capacitor/haptics` | `>=6.0.0 <9.0.0` | | `@capacitor/keyboard` | `>=6.0.0 <9.0.0` | | `@capacitor/network` | `>=6.0.0 <9.0.0` | @@ -47,6 +49,19 @@ Feature-scoped peers are only needed by the features that use them (`status-bar` ## Features +### KitRealtimeConnection + +An abstract Hibernation WebSocket client for application realtime services. Subclasses supply +connection intent and one or more `{ url, protocols }` targets; the kit owns foreground/network +suspension, all-target reconnect, exponential backoff, open and half-open detection, ping/pong, +self-echo annotation, and `reconnected$` resync signaling. Use `kitRealtimeProtocols()` to pass +authentication and the stable `KIT_REALTIME_CLIENT_ID` through WebSocket subprotocols without +putting credentials in the URL. + +Domain event types, authorization, room selection, and REST resync behavior remain in the app. + +--- + ### KitStorageService A typed wrapper around `@ionic/storage-angular` that guarantees writes are never silently dropped even when called immediately after service creation. diff --git a/projects/kit/package.json b/projects/kit/package.json index 5d481b1..a5ab182 100644 --- a/projects/kit/package.json +++ b/projects/kit/package.json @@ -13,6 +13,7 @@ "@ionic/angular": "^8.0.0", "@ionic/storage-angular": "^4.0.0", "@capacitor/core": ">=6.0.0 <9.0.0", + "@capacitor/app": ">=6.0.0 <9.0.0", "@capawesome/capacitor-live-update": ">=6.0.0 <9.0.0", "@capacitor/haptics": ">=6.0.0 <9.0.0", "@capacitor/keyboard": ">=6.0.0 <9.0.0", diff --git a/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts b/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts new file mode 100644 index 0000000..08ff3d5 --- /dev/null +++ b/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts @@ -0,0 +1,220 @@ +import type { PluginListenerHandle } from '@capacitor/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { kitRealtimeProtocols, KitRealtimeConnection, KitRealtimeLivenessWatchdog, toKitWebSocketUrl } from './kit-realtime-connection'; + +interface TestEvent { + topic: string; + originId?: string; +} + +class FakeWebSocket { + readyState: number = WebSocket.CONNECTING; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + readonly send = vi.fn(); + + open(): void { + this.readyState = WebSocket.OPEN; + this.onopen?.(new Event('open')); + } + + message(data: string): void { + this.onmessage?.(new MessageEvent('message', { data })); + } + + close(): void { + this.readyState = WebSocket.CLOSED; + this.onclose?.(new CloseEvent('close')); + } +} + +class TestConnection extends KitRealtimeConnection { + connectEnabled = true; + targetCount = 1; + failTargets = false; + failFailureHook = false; + failureCalls = 0; + readonly sockets: FakeWebSocket[] = []; + readonly removeAppListener = vi.fn(() => Promise.resolve()); + readonly removeNetworkListener = vi.fn(() => Promise.resolve()); + appListenerResolver: ((handle: PluginListenerHandle) => void) | null = null; + + constructor() { + super({ clientId: 'self', openTimeoutMs: 15_000, pingIntervalMs: 30_000, livenessTimeoutMs: 70_000 }); + } + + protected get shouldConnect(): boolean { + return this.connectEnabled; + } + + protected buildSocketTargets(): Promise<{ url: string; protocols: string[] }[]> { + if (this.failTargets) { + return Promise.reject(new Error('token failed')); + } + return Promise.resolve( + Array.from({ length: this.targetCount }, (_, index) => ({ + url: `https://example.test/realtime/${index}`, + protocols: ['test'], + })), + ); + } + + protected override handleConnectionFailure(): Promise { + this.failureCalls += 1; + if (this.failFailureHook) { + return Promise.reject(new Error('storage unavailable')); + } + return Promise.resolve(); + } + + protected override createWebSocket(): WebSocket { + const socket = new FakeWebSocket(); + this.sockets.push(socket); + return socket as unknown as WebSocket; + } + + protected override addAppStateListener(): Promise { + return new Promise((resolve) => { + this.appListenerResolver = resolve; + }); + } + + protected override addNetworkStatusListener(): Promise { + return Promise.resolve({ remove: this.removeNetworkListener }); + } + + openForTest(): Promise { + return this.open(); + } + + stop(): void { + this.connectEnabled = false; + this.suspend(); + } + + registerLifecycleForTest(): Promise { + return this.registerLifecycleListeners(); + } + + removeLifecycleForTest(): void { + this.removeLifecycleListeners(); + } +} + +describe('KitRealtimeConnection', () => { + afterEach(() => vi.useRealTimers()); + + it('converts endpoints and builds auth/client subprotocols', () => { + expect(toKitWebSocketUrl('https://example.test/realtime')).toBe('wss://example.test/realtime'); + expect(toKitWebSocketUrl('wss://example.test/realtime')).toBe('wss://example.test/realtime'); + expect(kitRealtimeProtocols('app-v1', { authToken: 'token', clientId: 'client' })).toEqual(['app-v1', 'auth.token', 'client.client']); + }); + + it('pings all targets and atomically reconnects after one closes', async () => { + vi.useFakeTimers(); + const connection = new TestConnection(); + connection.targetCount = 2; + await connection.openForTest(); + connection.sockets.forEach((socket) => socket.open()); + await vi.advanceTimersByTimeAsync(30_000); + expect(connection.sockets[0].send).toHaveBeenCalledWith('ping'); + expect(connection.sockets[1].send).toHaveBeenCalledWith('ping'); + + connection.sockets[0].close(); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(1000); + expect(connection.sockets).toHaveLength(4); + connection.stop(); + }); + + it('marks self echoes and expands event batches', async () => { + const connection = new TestConnection(); + const events: (TestEvent & { isSelf: boolean })[] = []; + connection.events$.subscribe((event) => events.push(event)); + await connection.openForTest(); + connection.sockets[0].open(); + connection.sockets[0].message( + JSON.stringify([ + { topic: 'one', originId: 'self' }, + { topic: 'two', originId: 'other' }, + ]), + ); + expect(events).toEqual([ + { topic: 'one', originId: 'self', isSelf: true }, + { topic: 'two', originId: 'other', isSelf: false }, + ]); + connection.stop(); + }); + + it('emits resync after a partial multi-target connection failure recovers', async () => { + vi.useFakeTimers(); + const connection = new TestConnection(); + connection.targetCount = 2; + const reconnected = vi.fn(); + connection.reconnected$.subscribe(reconnected); + await connection.openForTest(); + connection.sockets[0].open(); + connection.sockets[1].close(); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(1000); + connection.sockets[2].open(); + connection.sockets[3].open(); + expect(reconnected).toHaveBeenCalledOnce(); + connection.stop(); + }); + + it('retries target construction failure with backoff', async () => { + vi.useFakeTimers(); + const connection = new TestConnection(); + connection.failTargets = true; + await connection.openForTest(); + expect(connection.failureCalls).toBe(1); + connection.failTargets = false; + await vi.advanceTimersByTimeAsync(1000); + expect(connection.sockets).toHaveLength(1); + connection.stop(); + }); + + it('reconnects even when the connection-failure hook rejects', async () => { + vi.useFakeTimers(); + const connection = new TestConnection(); + connection.failFailureHook = true; + await connection.openForTest(); + connection.sockets[0].close(); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(1000); + expect(connection.sockets).toHaveLength(2); + connection.stop(); + }); + + it('removes an app listener that resolves after lifecycle teardown', async () => { + const connection = new TestConnection(); + const registering = connection.registerLifecycleForTest(); + connection.removeLifecycleForTest(); + connection.appListenerResolver?.({ remove: connection.removeAppListener }); + await registering; + expect(connection.removeAppListener).toHaveBeenCalledOnce(); + expect(connection.removeNetworkListener).not.toHaveBeenCalled(); + }); +}); + +describe('KitRealtimeLivenessWatchdog', () => { + afterEach(() => vi.useRealTimers()); + + it('times out and can be cleared', () => { + vi.useFakeTimers(); + const timeout = vi.fn(); + const watchdog = new KitRealtimeLivenessWatchdog(1000, timeout); + watchdog.reset(); + vi.advanceTimersByTime(999); + expect(timeout).not.toHaveBeenCalled(); + watchdog.clear(); + vi.advanceTimersByTime(1); + expect(timeout).not.toHaveBeenCalled(); + watchdog.reset(); + vi.advanceTimersByTime(1000); + expect(timeout).toHaveBeenCalledOnce(); + }); +}); diff --git a/projects/kit/src/lib/realtime/kit-realtime-connection.ts b/projects/kit/src/lib/realtime/kit-realtime-connection.ts new file mode 100644 index 0000000..3b3ba4f --- /dev/null +++ b/projects/kit/src/lib/realtime/kit-realtime-connection.ts @@ -0,0 +1,421 @@ +import { App } from '@capacitor/app'; +import type { PluginListenerHandle } from '@capacitor/core'; +import { Network } from '@capacitor/network'; +import type { Observable } from 'rxjs'; +import { Subject } from 'rxjs'; + +/** One WebSocket endpoint and its ordered subprotocol list. */ +export interface KitRealtimeSocketTarget { + url: string; + protocols: string[]; +} + +/** Realtime event shape required for self-echo identification. */ +export interface KitRealtimeEvent { + originId?: string; +} + +/** An application event annotated with whether it originated from this client. */ +export type KitClientRealtimeEvent = TEvent & { isSelf: boolean }; + +/** Timing and protocol options for {@link KitRealtimeConnection}. */ +export interface KitRealtimeConnectionOptions { + clientId?: string; + ping?: string; + pong?: string; + maxBackoffMs?: number; + openTimeoutMs?: number; + pingIntervalMs?: number; + livenessTimeoutMs?: number; +} + +interface SocketHealth { + lastActivityAt: number; + openTimer: ReturnType | null; + watchdog: KitRealtimeLivenessWatchdog; +} + +/** Stable per-tab/application-run ID shared by realtime connections and write headers. */ +export const KIT_REALTIME_CLIENT_ID = crypto.randomUUID(); + +/** Convert an HTTP(S) endpoint to its WebSocket equivalent. */ +export function toKitWebSocketUrl(url: string): string { + const parsed = new URL(url); + if (parsed.protocol === 'https:') { + parsed.protocol = 'wss:'; + } else if (parsed.protocol === 'http:') { + parsed.protocol = 'ws:'; + } + return parsed.toString(); +} + +/** Build the standard application/auth/client WebSocket subprotocol list. */ +export function kitRealtimeProtocols( + protocol: string, + options: { clientId?: string; authToken?: string; authPrefix?: string; clientPrefix?: string } = {}, +): string[] { + const protocols = [protocol]; + if (options.authToken) { + protocols.push(`${options.authPrefix ?? 'auth.'}${options.authToken}`); + } + protocols.push(`${options.clientPrefix ?? 'client.'}${options.clientId ?? KIT_REALTIME_CLIENT_ID}`); + return protocols; +} + +/** Detect a half-open WebSocket when no pong or event arrives before the timeout. */ +export class KitRealtimeLivenessWatchdog { + #timer: ReturnType | null = null; + + constructor( + private readonly timeoutMs: number, + private readonly onTimeout: () => void, + ) {} + + /** Restart the liveness deadline after receiving server activity. */ + reset(): void { + this.clear(); + this.#timer = setTimeout(() => { + this.#timer = null; + this.onTimeout(); + }, this.timeoutMs); + } + + /** Cancel the current liveness deadline. */ + clear(): void { + if (!this.#timer) { + return; + } + clearTimeout(this.#timer); + this.#timer = null; + } +} + +/** + * Reconnecting Hibernation WebSocket client for Ionic/Capacitor applications. + * + * Subclasses provide connection intent and one or more targets. The base owns foreground/network + * suspension, exponential backoff, open/liveness timeouts, runtime-friendly application pings, + * all-target atomic reconnect, and a resync signal after connectivity is restored. + */ +export abstract class KitRealtimeConnection { + readonly #events$ = new Subject>(); + readonly #reconnected$ = new Subject(); + readonly #options: Required; + + /** Client ID used to classify self echoes. */ + readonly id: string; + protected readonly listeners: PluginListenerHandle[] = []; + + #sockets = new Set(); + readonly #health = new Map(); + #opening = false; + #generation = 0; + #reconnectTimer: ReturnType | null = null; + #pingTimer: ReturnType | null = null; + #reconnectAttempt = 0; + #hasOpened = false; + #needsResync = false; + #isAppActive = true; + #isNetworkConnected = true; + #lifecycleRegistration: Promise | null = null; + #lifecycleGeneration = 0; + + /** All parsed events received by this connection. */ + readonly events$: Observable> = this.#events$.asObservable(); + /** Emits once after every fully restored connection cycle, prompting consumers to resync via REST. */ + readonly reconnected$: Observable = this.#reconnected$.asObservable(); + + protected constructor(options: KitRealtimeConnectionOptions = {}) { + this.#options = { + clientId: options.clientId ?? KIT_REALTIME_CLIENT_ID, + ping: options.ping ?? 'ping', + pong: options.pong ?? 'pong', + maxBackoffMs: options.maxBackoffMs ?? 30_000, + openTimeoutMs: options.openTimeoutMs ?? 15_000, + pingIntervalMs: options.pingIntervalMs ?? 30_000, + livenessTimeoutMs: options.livenessTimeoutMs ?? 70_000, + }; + this.id = this.#options.clientId; + } + + /** Whether every configured socket is currently open. */ + get isStreamOpen(): boolean { + return this.#sockets.size > 0 && [...this.#sockets].every((socket) => socket.readyState === WebSocket.OPEN); + } + + /** Whether every configured socket has produced recent server activity. */ + get isStreamHealthy(): boolean { + return ( + this.isStreamOpen && + [...this.#health.values()].every(({ lastActivityAt }) => Date.now() - lastActivityAt < this.#options.livenessTimeoutMs) + ); + } + + protected abstract get shouldConnect(): boolean; + protected abstract buildSocketTargets(): Promise; + + get #canOpen(): boolean { + return this.shouldConnect && this.#isAppActive && this.#isNetworkConnected; + } + + /** Hook used by authenticated clients to invalidate a token after handshake failure. */ + protected handleConnectionFailure(): Promise { + return Promise.resolve(); + } + + /** Parse a text WebSocket message into one or more domain events. */ + protected parseMessage(data: string): TEvent[] { + const parsed = JSON.parse(data) as TEvent | TEvent[]; + return Array.isArray(parsed) ? parsed : [parsed]; + } + + /** Factory seam for platform-specific WebSocket implementations and unit tests. */ + protected createWebSocket(url: string, protocols: string[]): WebSocket { + return new WebSocket(url, protocols); + } + + /** Factory seam for Capacitor app-state listeners. */ + protected addAppStateListener(listener: (state: { isActive: boolean }) => void): Promise { + return App.addListener('appStateChange', listener); + } + + /** Factory seam for Capacitor network-status listeners. */ + protected addNetworkStatusListener(listener: (status: { connected: boolean }) => void): Promise { + return Network.addListener('networkStatusChange', listener); + } + + /** Register foreground/network listeners exactly once, cleaning up handles that resolve after disconnect. */ + protected async registerLifecycleListeners(): Promise { + if (this.listeners.length > 0) { + return; + } + if (this.#lifecycleRegistration) { + return this.#lifecycleRegistration; + } + const generation = this.#lifecycleGeneration; + this.#lifecycleRegistration = (async () => { + const appHandle = await this.addAppStateListener(({ isActive }) => { + this.#isAppActive = isActive; + if (this.#canOpen) { + void this.open(); + } else { + this.suspend(); + } + }); + if (generation !== this.#lifecycleGeneration) { + await appHandle.remove(); + return; + } + this.listeners.push(appHandle); + + const networkHandle = await this.addNetworkStatusListener(({ connected }) => { + this.#isNetworkConnected = connected; + if (this.#canOpen) { + void this.open(); + } else { + this.suspend(); + } + }); + if (generation !== this.#lifecycleGeneration) { + await networkHandle.remove(); + return; + } + this.listeners.push(networkHandle); + })(); + try { + await this.#lifecycleRegistration; + } finally { + this.#lifecycleRegistration = null; + } + } + + /** Remove all lifecycle listeners, including listeners whose async registration has not completed yet. */ + protected removeLifecycleListeners(): void { + this.#lifecycleGeneration += 1; + this.listeners.forEach((handle) => void handle.remove()); + this.listeners.length = 0; + } + + /** Suspend sockets without changing the subclass connection intent. */ + protected suspend(): void { + this.#clearReconnectTimer(); + this.#closeSockets(); + } + + /** Reset resync and backoff history when the owning session ends. */ + protected resetConnectionState(): void { + this.#hasOpened = false; + this.#needsResync = false; + this.#reconnectAttempt = 0; + } + + /** Open every current target, or schedule an atomic reconnect if any target fails. */ + protected async open(): Promise { + if (!this.#canOpen || this.#opening || this.#sockets.size > 0) { + return; + } + this.#clearReconnectTimer(); + this.#opening = true; + const generation = ++this.#generation; + + try { + const targets = await this.buildSocketTargets(); + if (!this.#canOpen || generation !== this.#generation) { + return; + } + if (targets.length === 0) { + this.#opening = false; + return; + } + + const openedSockets = new Set(); + for (const target of targets) { + const socket = this.createWebSocket(toKitWebSocketUrl(target.url), target.protocols); + const health: SocketHealth = { + lastActivityAt: 0, + openTimer: null, + watchdog: new KitRealtimeLivenessWatchdog(this.#options.livenessTimeoutMs, () => this.#connectionFailed(generation)), + }; + this.#sockets.add(socket); + this.#health.set(socket, health); + health.openTimer = setTimeout(() => this.#connectionFailed(generation), this.#options.openTimeoutMs); + + socket.onopen = () => { + if (generation !== this.#generation) { + return; + } + this.#clearOpenTimer(health); + this.#markActivity(health); + this.#startPing(); + openedSockets.add(socket); + if (openedSockets.size !== targets.length) { + return; + } + this.#opening = false; + if (this.#hasOpened || this.#needsResync) { + this.#reconnected$.next(); + } + this.#hasOpened = true; + this.#needsResync = false; + }; + socket.onmessage = ({ data }) => { + if (generation !== this.#generation || typeof data !== 'string') { + return; + } + this.#reconnectAttempt = 0; + this.#markActivity(health); + if (data === this.#options.pong) { + return; + } + try { + for (const event of this.parseMessage(data)) { + this.#events$.next({ ...event, isSelf: event.originId === this.id }); + } + } catch { + // Ignore malformed application messages while retaining the healthy socket. + } + }; + socket.onerror = () => this.#connectionFailed(generation); + socket.onclose = () => this.#connectionFailed(generation); + } + } catch { + this.#connectionFailed(generation); + } finally { + if (generation === this.#generation && this.#sockets.size === 0) { + this.#opening = false; + } + } + } + + #connectionFailed(generation: number): void { + if (generation !== this.#generation) { + return; + } + if ([...this.#sockets].some((socket) => socket.readyState === WebSocket.OPEN)) { + this.#needsResync = true; + } + this.#closeSockets(); + void this.handleConnectionFailure() + .catch(() => undefined) + .finally(() => this.#scheduleReconnect()); + } + + #markActivity(health: SocketHealth): void { + health.lastActivityAt = Date.now(); + health.watchdog.reset(); + } + + #startPing(): void { + if (this.#pingTimer) { + return; + } + this.#pingTimer = setInterval(() => { + for (const socket of this.#sockets) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(this.#options.ping); + } + } + }, this.#options.pingIntervalMs); + } + + #closeSockets(): void { + this.#generation += 1; + this.#opening = false; + this.#clearPingTimer(); + const sockets = this.#sockets; + this.#sockets = new Set(); + for (const socket of sockets) { + const health = this.#health.get(socket); + if (health) { + health.watchdog.clear(); + this.#clearOpenTimer(health); + } + this.#health.delete(socket); + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + try { + socket.close(1000, 'client suspended'); + } catch { + // Reconnect processing continues even if a CONNECTING socket cannot close cleanly. + } + } + } + + #scheduleReconnect(): void { + if (!this.#canOpen || this.#reconnectTimer || this.#sockets.size > 0) { + return; + } + const delay = Math.min(1000 * 2 ** this.#reconnectAttempt, this.#options.maxBackoffMs); + this.#reconnectAttempt += 1; + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = null; + void this.open(); + }, delay); + } + + #clearOpenTimer(health: SocketHealth): void { + if (!health.openTimer) { + return; + } + clearTimeout(health.openTimer); + health.openTimer = null; + } + + #clearPingTimer(): void { + if (!this.#pingTimer) { + return; + } + clearInterval(this.#pingTimer); + this.#pingTimer = null; + } + + #clearReconnectTimer(): void { + if (!this.#reconnectTimer) { + return; + } + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } +} diff --git a/projects/kit/src/public-api.ts b/projects/kit/src/public-api.ts index 857412b..a51dc5d 100644 --- a/projects/kit/src/public-api.ts +++ b/projects/kit/src/public-api.ts @@ -37,6 +37,9 @@ export * from './lib/auth/auth-guards'; // HTTP: functional interceptor. export * from './lib/http/kit-http.interceptor'; +// Realtime: reconnecting Hibernation WebSocket client infrastructure. +export * from './lib/realtime/kit-realtime-connection'; + // Utils: framework-agnostic pure helpers. export * from './lib/utils/haptics'; export * from './lib/utils/array';