Skip to content
Open
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
7 changes: 7 additions & 0 deletions projects/kit/offline/src/lib/offline-coordinator.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { computed, inject, Injectable, signal } from '@angular/core';
import type { OfflinePrincipalId } from './offline-identity';
import { OFFLINE_KIT_OPTIONS } from './offline-kit-options';
import { OfflineNetworkService } from './offline-network.service';
import { DEFAULT_OFFLINE_CONNECTION_VERIFICATION_TIMEOUT_MS } from './offline-network.service';
import { OfflineMutationPersistenceService } from './offline-mutation-persistence.service';
import { OFFLINE_REPOSITORY } from './offline-repository';
import { OfflineSessionService } from './offline-session.service';
Expand Down Expand Up @@ -55,13 +56,19 @@ export class OfflineCoordinatorService {
readonly isStorageReady = computed(() => this.#storageState().status === 'ready');

readonly networkState = this.#network.state;
readonly checkingConnection = this.#network.checkingConnection;
readonly syncState = this.#sync.syncState;
readonly pendingCommands = this.#sync.pendingCommands;
readonly pendingCount = this.#sync.pendingCount;
readonly conflicts = this.#sync.conflicts;
/** Device-local control for accepting new durable Outbox mutations. */
readonly mutationPersistence = this.#mutationPersistence;

/** Immediately verifies remote API reachability without consulting the local replica. */
verifyConnection(url: string, timeoutMs = DEFAULT_OFFLINE_CONNECTION_VERIFICATION_TIMEOUT_MS): Promise<boolean> {
return this.#network.verifyConnection(url, timeoutMs);
}

/** Opens local storage and restores its persisted session boundary without waiting for network discovery. */
initializeLocal(): Promise<void> {
this.#localInitialization ??= this.#initializeLocal();
Expand Down
101 changes: 101 additions & 0 deletions projects/kit/offline/src/lib/offline-network-verification.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { OfflineNetworkService } from './offline-network.service';
import { OFFLINE_BYPASS, OFFLINE_IGNORE_TRANSPORT_FAILURE } from './offline-request-policy';

describe('OfflineNetworkService connection verification', () => {
let http: HttpTestingController;
let service: OfflineNetworkService;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [OfflineNetworkService, provideHttpClient(), provideHttpClientTesting()],
});
http = TestBed.inject(HttpTestingController);
service = TestBed.inject(OfflineNetworkService);
});

afterEach(() => {
http.verify();
vi.useRealTimers();
});

it('bypasses local fallback and records a successful API check', async () => {
const markApiSuccess = vi.spyOn(service, 'markApiSuccess');
const result = service.verifyConnection('/status');
expect(service.checkingConnection()).toBe(true);
const request = http.expectOne('/status');
expect(request.request.context.get(OFFLINE_BYPASS)).toBe(true);
expect(request.request.context.get(OFFLINE_IGNORE_TRANSPORT_FAILURE)).toBe(true);
request.flush({});

await expect(result).resolves.toBe(true);
expect(markApiSuccess).toHaveBeenCalledOnce();
expect(service.checkingConnection()).toBe(false);
});

it('shares the service-wide in-flight check and permits another check after it settles', async () => {
const first = service.verifyConnection('/status');
const second = service.verifyConnection('/another-status');
expect(second).toBe(first);
http.expectOne('/status').flush({});
http.expectNone('/another-status');
await Promise.all([first, second]);

const third = service.verifyConnection('/status');
http.expectOne('/status').flush({});
await expect(third).resolves.toBe(true);
});

it('does not classify an HTTP error as a disconnected transport', async () => {
const markApiFailure = vi.spyOn(service, 'markApiFailure');
const result = service.verifyConnection('/status');
http.expectOne('/status').flush({}, { status: 500, statusText: 'Server Error' });

await expect(result).resolves.toBe(false);
expect(markApiFailure).not.toHaveBeenCalled();
expect(service.checkingConnection()).toBe(false);
});

it('records a status-zero failure when no newer API observation exists and permits retry', async () => {
const markApiFailure = vi.spyOn(service, 'markApiFailure');
const first = service.verifyConnection('/status');
http.expectOne('/status').error(new ProgressEvent('error'));
await expect(first).resolves.toBe(false);
expect(markApiFailure).toHaveBeenCalledOnce();
expect(service.state()).toBe('offline');

const second = service.verifyConnection('/status');
http.expectOne('/status').flush({});
await expect(second).resolves.toBe(true);
});

it('does not let an older probe failure overwrite a newer successful API observation', async () => {
const markApiFailure = vi.spyOn(service, 'markApiFailure');
const verification = service.verifyConnection('/status');
service.markApiSuccess();
http.expectOne('/status').error(new ProgressEvent('error'));

await expect(verification).resolves.toBe(false);
expect(markApiFailure).not.toHaveBeenCalled();
expect(service.state()).toBe('unverified');
});

it('does not overwrite reachability when a stalled check times out and permits retry', async () => {
vi.useFakeTimers();
const markApiFailure = vi.spyOn(service, 'markApiFailure');
const first = service.verifyConnection('/status', 10);
http.expectOne('/status');
await vi.advanceTimersByTimeAsync(10);

await expect(first).resolves.toBe(false);
expect(markApiFailure).not.toHaveBeenCalled();
expect(service.checkingConnection()).toBe(false);

const second = service.verifyConnection('/status', 10);
http.expectOne('/status').flush({});
await expect(second).resolves.toBe(true);
});
});
17 changes: 7 additions & 10 deletions projects/kit/offline/src/lib/offline-network.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { PluginListenerHandle } from '@capacitor/core';
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { OfflineNetworkService } from './offline-network.service';

Expand All @@ -16,16 +18,12 @@ class TestOfflineNetworkService extends OfflineNetworkService {
return this.getNetworkStatusMock();
}

protected override async addAppStateListener(
listener: (state: { isActive: boolean }) => void,
): Promise<PluginListenerHandle> {
protected override async addAppStateListener(listener: (state: { isActive: boolean }) => void): Promise<PluginListenerHandle> {
this.appListener = listener;
return { remove: vi.fn(async () => undefined) };
}

protected override async addNetworkStatusListener(
listener: (state: { connected: boolean }) => void,
): Promise<PluginListenerHandle> {
protected override async addNetworkStatusListener(listener: (state: { connected: boolean }) => void): Promise<PluginListenerHandle> {
this.networkListener = listener;
return { remove: vi.fn(async () => undefined) };
}
Expand All @@ -35,7 +33,8 @@ describe('OfflineNetworkService', () => {
let service: TestOfflineNetworkService;

beforeEach(() => {
service = new TestOfflineNetworkService();
TestBed.configureTestingModule({ providers: [{ provide: HttpClient, useValue: {} }] });
service = TestBed.runInInjectionContext(() => new TestOfflineNetworkService());
service.getAppStateMock.mockResolvedValue({ isActive: true });
service.getNetworkStatusMock.mockResolvedValue({ connected: true });
});
Expand All @@ -53,9 +52,7 @@ describe('OfflineNetworkService', () => {

it('listener登録後のappStateChangeを遅延した初期stateで上書きしない', async () => {
let resolveInitialState!: (state: { isActive: boolean }) => void;
service.getAppStateMock.mockImplementation(
() => new Promise((resolve) => (resolveInitialState = resolve)),
);
service.getAppStateMock.mockImplementation(() => new Promise((resolve) => (resolveInitialState = resolve)));
const initialization = service.initialize();
await vi.waitFor(() => {
expect(service.appListener).not.toBeNull();
Expand Down
52 changes: 51 additions & 1 deletion projects/kit/offline/src/lib/offline-network.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { computed, Injectable, signal } from '@angular/core';
import { HttpClient, HttpContext } from '@angular/common/http';
import { computed, inject, Injectable, signal } from '@angular/core';
import { App } from '@capacitor/app';
import type { PluginListenerHandle } from '@capacitor/core';
import { Network } from '@capacitor/network';
import { catchError, firstValueFrom, map, of, timeout } from 'rxjs';
import { OFFLINE_BYPASS, OFFLINE_IGNORE_TRANSPORT_FAILURE } from './offline-request-policy';

export const DEFAULT_OFFLINE_CONNECTION_VERIFICATION_TIMEOUT_MS = 8_000;

export type OfflineNetworkState = 'online' | 'offline' | 'unverified';

Expand All @@ -17,9 +22,14 @@ export class OfflineNetworkService {
readonly #apiReachable = signal<boolean | null>(null);
readonly #appActive = signal(true);
readonly #lifecycleRevision = signal(0);
#apiReachabilityRevision = 0;
#networkRevision = 0;
readonly #listeners: PluginListenerHandle[] = [];
#initialized = false;
readonly #checkingConnection = signal(false);
#connectionVerification: Promise<boolean> | null = null;

readonly #http = inject(HttpClient);

readonly state = computed<OfflineNetworkState>(() => {
if (this.#osConnected() === false || this.#apiReachable() === false) return 'offline';
Expand All @@ -31,6 +41,8 @@ export class OfflineNetworkService {
readonly appActive = this.#appActive.asReadonly();
/** Changes on every foreground/background transition, even when connectivity is unchanged. */
readonly lifecycleRevision = this.#lifecycleRevision.asReadonly();
/** Whether an explicit remote API reachability check is running. */
readonly checkingConnection = this.#checkingConnection.asReadonly();

async initialize(): Promise<void> {
if (this.#initialized) return;
Expand All @@ -56,13 +68,51 @@ export class OfflineNetworkService {
}

markApiSuccess(): void {
this.#apiReachabilityRevision += 1;
this.#apiReachable.set(true);
}

markApiFailure(): void {
this.#apiReachabilityRevision += 1;
this.#apiReachable.set(false);
}

/**
* Runs one remote-only reachability check for this service instance and updates the observed API state.
* While it is running, every caller shares that check; products should therefore use one stable health endpoint.
*/
verifyConnection(url: string, timeoutMs = DEFAULT_OFFLINE_CONNECTION_VERIFICATION_TIMEOUT_MS): Promise<boolean> {
if (this.#connectionVerification) return this.#connectionVerification;

const startingApiReachabilityRevision = this.#apiReachabilityRevision;
this.#checkingConnection.set(true);
const verification = firstValueFrom(
this.#http
.get(url, {
context: new HttpContext().set(OFFLINE_BYPASS, true).set(OFFLINE_IGNORE_TRANSPORT_FAILURE, true),
Comment thread
rdlabo marked this conversation as resolved.
observe: 'response',
})
.pipe(
timeout({ first: timeoutMs }),
map(() => {
this.markApiSuccess();
return true;
}),
catchError((error: unknown) => {
if (isOfflineFallbackError(error) && this.#apiReachabilityRevision === startingApiReachabilityRevision) {
this.markApiFailure();
}
return of(false);
}),
),
).finally(() => {
this.#checkingConnection.set(false);
this.#connectionVerification = null;
});
this.#connectionVerification = verification;
return verification;
}

/** Factory seam for Capacitor app-state discovery and deterministic tests. */
protected getAppState(): Promise<{ isActive: boolean }> {
return App.getState();
Expand Down
2 changes: 2 additions & 0 deletions projects/kit/offline/src/lib/offline-request-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { HttpContextToken } from '@angular/common/http';

/** outbox再送時にoffline interceptorだけを迂回する。認証・retryは維持する。 */
export const OFFLINE_BYPASS = new HttpContextToken<boolean>(() => false);
/** Lets a manual reachability probe apply its transport failure with its own observation-order guard. */
export const OFFLINE_IGNORE_TRANSPORT_FAILURE = new HttpContextToken<boolean>(() => false);
/** Header attached to synthetic local or optimistic responses. */
export const OFFLINE_RESPONSE_HEADER = 'X-Offline-Response';
/** Header marking a local response as a complete collection snapshot. */
Expand Down
12 changes: 12 additions & 0 deletions projects/kit/offline/src/lib/offline.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { OFFLINE_REPOSITORY } from './offline-repository';
import { offlineInterceptor } from './offline.interceptor';
import {
OFFLINE_BYPASS,
OFFLINE_IGNORE_TRANSPORT_FAILURE,
OFFLINE_RESPONSE_HEADER,
type OfflineMutationRequestPlan,
OfflineMutationRequestPolicyRegistry,
Expand Down Expand Up @@ -64,6 +65,17 @@ describe('offlineInterceptor', () => {
expect(resolveMutation).not.toHaveBeenCalled();
});

it('manual probeのtransport失敗は新しいAPI成功状態を上書きしない', async () => {
const error = new HttpErrorResponse({ status: 0, error: new Error('offline') });
const request = new HttpRequest('GET', '/status', {
context: new HttpContext().set(OFFLINE_BYPASS, true).set(OFFLINE_IGNORE_TRANSPORT_FAILURE, true),
});

await expect(firstValueFrom(run(request, () => throwError(() => error)))).rejects.toBe(error);

expect(markApiFailure).not.toHaveBeenCalled();
});

it('GET成功はtransport responseをそのまま返しreachabilityを更新する', async () => {
resolve.mockReturnValue({ kind: 'read', readLocal: vi.fn() });
const response = new HttpResponse({ body: { userId: 1 }, status: 200 });
Expand Down
11 changes: 8 additions & 3 deletions projects/kit/offline/src/lib/offline.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { OfflineReplicaMutationCoordinator } from './offline-replica-mutation-co
import { OFFLINE_MUTATION_PERSISTENCE_ENABLED } from './offline-mutation-persistence.service';
import {
OFFLINE_BYPASS,
OFFLINE_IGNORE_TRANSPORT_FAILURE,
OFFLINE_RESPONSE_HEADER,
OfflineMutationRequestPolicyRegistry,
OfflineRequestPolicyRegistry,
Expand All @@ -41,7 +42,7 @@ type MaterializedTransport = Notification<HttpEvent<unknown>> & ObservableNotifi
/** Applies product read and local-first mutation policies while observing real API reachability. */
export const offlineInterceptor: HttpInterceptorFn = (request, next) => {
const network = inject(OfflineNetworkService);
const transport = () => observeTransport(next(request), network);
const transport = () => observeTransport(next(request), network, !request.context.get(OFFLINE_IGNORE_TRANSPORT_FAILURE));
if (request.context.get(OFFLINE_BYPASS)) return transport();
if (request.method === 'GET') {
const registry = inject(OfflineRequestPolicyRegistry);
Expand Down Expand Up @@ -270,7 +271,11 @@ function projectReadResponse(
);
}

function observeTransport(source: Observable<HttpEvent<unknown>>, network: OfflineNetworkService): Observable<HttpEvent<unknown>> {
function observeTransport(
source: Observable<HttpEvent<unknown>>,
network: OfflineNetworkService,
observeFailure: boolean,
): Observable<HttpEvent<unknown>> {
return source.pipe(
tap({
next: (event) => {
Expand All @@ -282,7 +287,7 @@ function observeTransport(source: Observable<HttpEvent<unknown>>, network: Offli
network.markApiSuccess();
},
error: (error: unknown) => {
if (isOfflineFallbackError(error)) network.markApiFailure();
if (observeFailure && isOfflineFallbackError(error)) network.markApiFailure();
},
}),
);
Expand Down