Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, …)

Expand All @@ -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` |
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions projects/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
220 changes: 220 additions & 0 deletions projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts
Original file line number Diff line number Diff line change
@@ -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<TestEvent> {
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<void> {
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<PluginListenerHandle> {
return new Promise((resolve) => {
this.appListenerResolver = resolve;
});
}

protected override addNetworkStatusListener(): Promise<PluginListenerHandle> {
return Promise.resolve({ remove: this.removeNetworkListener });
}

openForTest(): Promise<void> {
return this.open();
}

stop(): void {
this.connectEnabled = false;
this.suspend();
}

registerLifecycleForTest(): Promise<void> {
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();
});
});
Loading