diff --git a/README.md b/README.md index 7b877a7..b1b0db8 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,19 @@ Sponsoring means you directly contribute to new features, improvements, and main ## Support Version | Angular | Package version | -|---------|-----------------| +| ------- | --------------- | | v20 | 20.x.x | | v19 | 19.x.x | | v18 | 2.x.x | - ## packages -| package name | description | path | -|-------------------------------------|--------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------| -| @rdlabo/ionic-angular-kit | Auth guards, Firebase flows, storage, overlay, HTTP interceptor, and other fleet helpers. | [/projects/kit](https://github.com/rdlabo-team/ionic-angular-library/tree/main/projects/kit#readme) | -| @rdlabo/ionic-angular-photo-editor | This is a photo editor and viewer for modal page of Ionic Angular project using Capacitor. | [/project/photo-editor](https://github.com/rdlabo-team/ionic-angular-library/tree/main/projects/photo-editor#readme) | -| @rdlabo/ionic-angular-scroll-header | This is directive for scroll with Header. | [/project/scroll-header](https://github.com/rdlabo-team/ionic-angular-library/tree/main/projects/scroll-header#readme) | -| @rdlabo/ngx-cdk-scroll-strategies | This is directive for virtual scroll of dynamic item size. | [/project/scroll-strategies](https://github.com/rdlabo-team/ionic-angular-library/tree/main/projects/scroll-strategies#readme) | +| package name | description | path | +| ----------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| @rdlabo/ionic-angular-kit | Auth guards, Firebase flows, storage, overlay, HTTP interceptor, and other fleet helpers. | [/projects/kit](https://github.com/rdlabo-team/ionic-angular-library/tree/main/projects/kit#readme) | +| @rdlabo/ionic-angular-photo-editor | This is a photo editor and viewer for modal page of Ionic Angular project using Capacitor. | [/project/photo-editor](https://github.com/rdlabo-team/ionic-angular-library/tree/main/projects/photo-editor#readme) | +| @rdlabo/ionic-angular-scroll-header | This is directive for scroll with Header. | [/project/scroll-header](https://github.com/rdlabo-team/ionic-angular-library/tree/main/projects/scroll-header#readme) | +| @rdlabo/ngx-cdk-scroll-strategies | This is directive for virtual scroll of dynamic item size. | [/project/scroll-strategies](https://github.com/rdlabo-team/ionic-angular-library/tree/main/projects/scroll-strategies#readme) | ### Release @@ -43,7 +42,6 @@ The demo app includes a **Kit** tab with a Firebase Auth harness (`/main/kit/aut 3. `npm run e2e` — Playwright signs up with a UUID email; `window.__E2E__` skips email confirmation. 4. `npm run cap` — copy a production build to iOS/Android for device checks (e.g. `kitAuthInput` autofill). - ## sponsors This is an Apache-2.0-licensed open source project. It can grow thanks to the support by these awesome people. If you'd like to join them, please read more [here](https://github.com/sponsors/rdlabo-team) . diff --git a/package-lock.json b/package-lock.json index 72813a9..bccaa10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rdlabo/ionic-angular-library", - "version": "21.3.2", + "version": "21.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rdlabo/ionic-angular-library", - "version": "21.3.2", + "version": "21.3.3", "dependencies": { "@angular/animations": "^21.0.0", "@angular/common": "^21.0.0", diff --git a/package.json b/package.json index a175cc8..f967b41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rdlabo/ionic-angular-library", - "version": "21.3.2", + "version": "21.3.3", "repository": { "type": "git", "url": "git@github.com:rdlabo-team/ionic-angular-library.git" diff --git a/projects/kit/README.md b/projects/kit/README.md index b6d5ff9..eb66455 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -53,7 +53,7 @@ Feature-scoped peers are only needed by the features that use them (`status-bar` 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, +suspension, target-scoped reconnect that preserves healthy sockets, 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. diff --git a/projects/kit/package.json b/projects/kit/package.json index 302fd34..244b59d 100644 --- a/projects/kit/package.json +++ b/projects/kit/package.json @@ -1,6 +1,6 @@ { "name": "@rdlabo/ionic-angular-kit", - "version": "21.3.2", + "version": "21.3.3", "repository": { "type": "git", "url": "https://github.com/rdlabo-team/ionic-angular-library.git" diff --git a/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts b/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts index 1505a5e..246a227 100644 --- a/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts +++ b/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts @@ -37,6 +37,7 @@ class TestConnection extends KitRealtimeConnection { targetCount = 1; failTargets = false; failFailureHook = false; + pendingFailureHook: Promise | null = null; failureCalls = 0; readonly sockets: FakeWebSocket[] = []; readonly removeAppListener = vi.fn(() => Promise.resolve()); @@ -65,6 +66,9 @@ class TestConnection extends KitRealtimeConnection { protected override handleConnectionFailure(): Promise { this.failureCalls += 1; + if (this.pendingFailureHook) { + return this.pendingFailureHook; + } if (this.failFailureHook) { return Promise.reject(new Error('storage unavailable')); } @@ -128,7 +132,7 @@ describe('KitRealtimeConnection', () => { expect(TestBed.inject(InheritedConstructorConnection)).toBeInstanceOf(InheritedConstructorConnection); }); - it('pings all targets and atomically reconnects after one closes', async () => { + it('pings all targets and reconnects only the target that closes', async () => { vi.useFakeTimers(); const connection = new TestConnection(); connection.targetCount = 2; @@ -141,7 +145,8 @@ describe('KitRealtimeConnection', () => { connection.sockets[0].close(); await vi.runAllTicks(); await vi.advanceTimersByTimeAsync(1000); - expect(connection.sockets).toHaveLength(4); + expect(connection.sockets).toHaveLength(3); + expect(connection.sockets[1].readyState).toBe(WebSocket.OPEN); connection.stop(); }); @@ -181,15 +186,104 @@ describe('KitRealtimeConnection', () => { const connection = new TestConnection(); connection.targetCount = 2; const reconnected = vi.fn(); + const events: TestEvent[] = []; connection.reconnected$.subscribe(reconnected); + connection.events$.subscribe((event) => events.push(event)); 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(); + connection.sockets[0].message(JSON.stringify({ topic: 'healthy-target' })); expect(reconnected).toHaveBeenCalledOnce(); + expect(events).toContainEqual(expect.objectContaining({ topic: 'healthy-target' })); + connection.stop(); + }); + + it('does not amplify repeated single-target failures into all-target reconnect waves', async () => { + vi.useFakeTimers(); + const connection = new TestConnection(); + connection.targetCount = 20; + await connection.openForTest(); + connection.sockets.forEach((socket) => socket.open()); + let failedSocket = connection.sockets[0]; + + for (let attempt = 0; attempt < 100; attempt += 1) { + if (attempt % 20 === 0) { + connection.sockets.filter((socket) => socket.readyState === WebSocket.OPEN).forEach((socket) => socket.message('pong')); + } + failedSocket.close(); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(1000); + failedSocket = connection.sockets.at(-1)!; + failedSocket.open(); + } + + expect(connection.sockets).toHaveLength(120); + expect(connection.sockets.slice(1, 20).every((socket) => socket.readyState === WebSocket.OPEN)).toBe(true); + connection.stop(); + }); + + it('closes every remaining socket when a retry resolves to an empty target set', async () => { + vi.useFakeTimers(); + const connection = new TestConnection(); + connection.targetCount = 2; + await connection.openForTest(); + connection.sockets.forEach((socket) => socket.open()); + connection.targetCount = 0; + + connection.sockets[0].close(); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(1000); + + expect(connection.sockets[1].readyState).toBe(WebSocket.CLOSED); + expect(connection.isStreamOpen).toBe(false); + connection.stop(); + }); + + it('coalesces concurrent target failures into one retry while retaining healthy targets', async () => { + vi.useFakeTimers(); + const connection = new TestConnection(); + connection.targetCount = 3; + await connection.openForTest(); + connection.sockets.forEach((socket) => socket.open()); + + connection.sockets[0].close(); + connection.sockets[1].close(); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(1000); + + expect(connection.failureCalls).toBe(1); + expect(connection.sockets).toHaveLength(5); + expect(connection.sockets[2].readyState).toBe(WebSocket.OPEN); + connection.stop(); + }); + + it('ignores a failure hook that resolves after a new connection generation starts', async () => { + vi.useFakeTimers(); + let resolveOldFailure!: () => void; + const connection = new TestConnection(); + connection.pendingFailureHook = new Promise((resolve) => { + resolveOldFailure = resolve; + }); + await connection.openForTest(); + connection.sockets[0].open(); + + connection.sockets[0].close(); + await vi.runAllTicks(); + connection.stop(); + connection.connectEnabled = true; + connection.pendingFailureHook = null; + await connection.openForTest(); + connection.sockets[1].open(); + + resolveOldFailure(); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(connection.sockets).toHaveLength(2); + expect(connection.sockets[1].readyState).toBe(WebSocket.OPEN); connection.stop(); }); diff --git a/projects/kit/src/lib/realtime/kit-realtime-connection.ts b/projects/kit/src/lib/realtime/kit-realtime-connection.ts index 57cc6be..fd8fccc 100644 --- a/projects/kit/src/lib/realtime/kit-realtime-connection.ts +++ b/projects/kit/src/lib/realtime/kit-realtime-connection.ts @@ -5,7 +5,7 @@ import { Injectable } from '@angular/core'; import type { Observable } from 'rxjs'; import { Subject } from 'rxjs'; -/** One WebSocket endpoint and its ordered subprotocol list. */ +/** One WebSocket endpoint and its ordered subprotocol list. URLs must be unique within a target set. */ export interface KitRealtimeSocketTarget { url: string; protocols: string[]; @@ -31,6 +31,7 @@ export interface KitRealtimeConnectionOptions { } interface SocketHealth { + key: string; lastActivityAt: number; openTimer: ReturnType | null; watchdog: KitRealtimeLivenessWatchdog; @@ -96,7 +97,7 @@ export class KitRealtimeLivenessWatchdog { * * 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. + * target-scoped reconnect, and a resync signal after connectivity is restored. */ @Injectable() export abstract class KitRealtimeConnection { @@ -106,11 +107,13 @@ export abstract class KitRealtimeConnection { /** Client ID used to classify self echoes. */ protected readonly listeners: PluginListenerHandle[] = []; - #sockets = new Set(); + #sockets = new Map(); readonly #health = new Map(); + #targets = new Map(); #opening = false; #generation = 0; #reconnectTimer: ReturnType | null = null; + #reconnectPendingGeneration: number | null = null; #pingTimer: ReturnType | null = null; #reconnectAttempt = 0; #isAppActive = true; @@ -148,7 +151,11 @@ export abstract class KitRealtimeConnection { /** Whether every configured socket is currently open. */ get isStreamOpen(): boolean { - return this.#sockets.size > 0 && [...this.#sockets].every((socket) => socket.readyState === WebSocket.OPEN); + return ( + this.#targets.size > 0 && + this.#sockets.size === this.#targets.size && + [...this.#sockets.values()].every((socket) => socket.readyState === WebSocket.OPEN) + ); } /** Whether every configured socket has produced recent server activity. */ @@ -255,56 +262,67 @@ export abstract class KitRealtimeConnection { this.#reconnectAttempt = 0; } - /** Open every current target, or schedule an atomic reconnect if any target fails. */ + /** Open missing targets, preserving healthy sockets when another target fails. */ protected async open(): Promise { - if (!this.#canOpen || this.#opening || this.#sockets.size > 0) { + const hasMissingTarget = this.#targets.size === 0 || [...this.#targets.keys()].some((key) => !this.#sockets.has(key)); + if (!this.#canOpen || this.#opening || (this.#sockets.size > 0 && !hasMissingTarget)) { return; } this.#clearReconnectTimer(); this.#opening = true; - const generation = ++this.#generation; + 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 nextTargets = new Map(targets.map((target) => [toKitWebSocketUrl(target.url), target])); + for (const [key, socket] of this.#sockets) { + if (nextTargets.has(key)) { + continue; + } + const health = this.#health.get(socket); + if (health) { + this.#removeSocket(socket, health); + } + } + this.#targets = nextTargets; + if (this.#sockets.size === 0) { + this.#clearPingTimer(); } - - const openedSockets = new Set(); for (const target of targets) { - const socket = this.createWebSocket(toKitWebSocketUrl(target.url), target.protocols); + const key = toKitWebSocketUrl(target.url); + if (this.#sockets.has(key)) { + continue; + } + const socket = this.createWebSocket(key, target.protocols); const health: SocketHealth = { + key, lastActivityAt: 0, openTimer: null, - watchdog: new KitRealtimeLivenessWatchdog(this.#options.livenessTimeoutMs, () => this.#connectionFailed(generation)), + watchdog: new KitRealtimeLivenessWatchdog(this.#options.livenessTimeoutMs, () => this.#connectionFailed(generation, socket)), }; - this.#sockets.add(socket); + this.#sockets.set(key, socket); this.#health.set(socket, health); - health.openTimer = setTimeout(() => this.#connectionFailed(generation), this.#options.openTimeoutMs); + health.openTimer = setTimeout(() => this.#connectionFailed(generation, socket), this.#options.openTimeoutMs); socket.onopen = () => { - if (generation !== this.#generation) { + if (generation !== this.#generation || this.#sockets.get(key) !== socket) { return; } this.#clearOpenTimer(health); this.#markActivity(health); this.#startPing(); - openedSockets.add(socket); - if (openedSockets.size !== targets.length) { - return; + if (this.isStreamOpen) { + this.#reconnectAttempt = 0; + this.#reconnected$.next(); } - this.#opening = false; - this.#reconnected$.next(); }; socket.onmessage = ({ data }) => { - if (generation !== this.#generation || typeof data !== 'string') { + if (generation !== this.#generation || this.#sockets.get(key) !== socket || typeof data !== 'string') { return; } - this.#reconnectAttempt = 0; this.#markActivity(health); if (data === this.#options.pong) { return; @@ -317,26 +335,48 @@ export abstract class KitRealtimeConnection { // Ignore malformed application messages while retaining the healthy socket. } }; - socket.onerror = () => this.#connectionFailed(generation); - socket.onclose = () => this.#connectionFailed(generation); + socket.onerror = () => this.#connectionFailed(generation, socket); + socket.onclose = () => this.#connectionFailed(generation, socket); } + this.#opening = false; } catch { - this.#connectionFailed(generation); + this.#opening = false; + this.#requestReconnect(); } finally { - if (generation === this.#generation && this.#sockets.size === 0) { + if (generation === this.#generation) { this.#opening = false; } } } - #connectionFailed(generation: number): void { - if (generation !== this.#generation) { + #connectionFailed(generation: number, socket: WebSocket): void { + const health = this.#health.get(socket); + if (generation !== this.#generation || !health || this.#sockets.get(health.key) !== socket) { return; } - this.#closeSockets(); + this.#removeSocket(socket, health); + if (this.#sockets.size === 0) { + this.#clearPingTimer(); + } + this.#requestReconnect(); + } + + #requestReconnect(): void { + const generation = this.#generation; + if (!this.#canOpen || this.#reconnectPendingGeneration === generation || this.#reconnectTimer) { + return; + } + this.#reconnectPendingGeneration = generation; void this.handleConnectionFailure() .catch(() => undefined) - .finally(() => this.#scheduleReconnect()); + .finally(() => { + if (this.#reconnectPendingGeneration === generation) { + this.#reconnectPendingGeneration = null; + } + if (generation === this.#generation) { + this.#scheduleReconnect(); + } + }); } #markActivity(health: SocketHealth): void { @@ -349,7 +389,7 @@ export abstract class KitRealtimeConnection { return; } this.#pingTimer = setInterval(() => { - for (const socket of this.#sockets) { + for (const socket of this.#sockets.values()) { if (socket.readyState === WebSocket.OPEN) { socket.send(this.#options.ping); } @@ -362,28 +402,36 @@ export abstract class KitRealtimeConnection { this.#opening = false; this.#clearPingTimer(); const sockets = this.#sockets; - this.#sockets = new Set(); - for (const socket of sockets) { + this.#sockets = new Map(); + this.#targets = new Map(); + for (const socket of sockets.values()) { 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. + this.#removeSocket(socket, health, false); } } } + #removeSocket(socket: WebSocket, health: SocketHealth, removeFromCurrent = true): void { + health.watchdog.clear(); + this.#clearOpenTimer(health); + this.#health.delete(socket); + if (removeFromCurrent && this.#sockets.get(health.key) === socket) { + this.#sockets.delete(health.key); + } + 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) { + if (!this.#canOpen || this.#reconnectTimer) { return; } const delay = Math.min(1000 * 2 ** this.#reconnectAttempt, this.#options.maxBackoffMs); diff --git a/projects/photo-editor/package.json b/projects/photo-editor/package.json index 19e6249..59efde8 100644 --- a/projects/photo-editor/package.json +++ b/projects/photo-editor/package.json @@ -1,6 +1,6 @@ { "name": "@rdlabo/ionic-angular-photo-editor", - "version": "21.3.2", + "version": "21.3.3", "repository": { "type": "git", "url": "git@github.com:rdlabo-team/ionic-angular-library.git" diff --git a/projects/scroll-header/package.json b/projects/scroll-header/package.json index 65f2067..f8bb9c6 100644 --- a/projects/scroll-header/package.json +++ b/projects/scroll-header/package.json @@ -1,6 +1,6 @@ { "name": "@rdlabo/ionic-angular-scroll-header", - "version": "21.3.2", + "version": "21.3.3", "repository": { "type": "git", "url": "git@github.com:rdlabo-team/ionic-angular-library.git" diff --git a/projects/scroll-strategies/package.json b/projects/scroll-strategies/package.json index 1559c4c..db91bc2 100644 --- a/projects/scroll-strategies/package.json +++ b/projects/scroll-strategies/package.json @@ -1,6 +1,6 @@ { "name": "@rdlabo/ngx-cdk-scroll-strategies", - "version": "21.3.2", + "version": "21.3.3", "repository": { "type": "git", "url": "git@github.com:rdlabo-team/ionic-angular-library.git"