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
31 changes: 31 additions & 0 deletions projects/kit/docs/offline-realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,37 @@ not rely on ordering between guards declared in the same `canActivate` array.
Keep `provideOffline()` for existing root installations. Moving a provider is an application design
choice; adopting the new API is not a required migration.

### Defer remote work until authenticated content is visible

Route-scoped applications can open only the local substrate on the activation path, then resume
pull and Outbox transport after their first useful content has rendered. Existing applications keep
the blocking behavior unless they opt in to both settings below.

```ts
const offlineReadyGuard: CanActivateFn = async () => {
await inject(OfflineRouteInitializerService).initialize({ remote: 'deferred' });
return true;
};

createOfflineAuthBridge({
exchange,
currentAuthSubject,
isUnavailableError,
resumeMode: 'background',
beforeRemoteResume: () => authenticatedContentReady.wait(),
});
```

`activate` still installs and lease-checks the remotely verified identity before the guard grants
access. Only `resumeRemoteSession()` is deferred. Keep `resumeMode: 'blocking'` when the route needs
the first pull or Outbox replay before it can render safely. A readiness promise should include a
bounded fallback so a deep link that does not render the primary content cannot suspend transport
indefinitely. Always call `startRemoteRuntime()` after that same boundary, including when the
credential exchange falls back to local access. This installs network discovery so an offline start
can recover immediately when connectivity returns. Start the fallback timer only after local access
has been granted; starting it in the local initializer can launch the remote runtime while a slow
credential exchange is still pending.

## Realtime connection

Subclass `KitRealtimeConnection` to supply connection intent and `{ url, protocols }` targets. The kit owns foreground and network suspension, target-scoped reconnect, exponential backoff, ping/pong detection, self-echo annotation, and `reconnected$` resync signaling.
Expand Down
27 changes: 27 additions & 0 deletions projects/kit/offline/src/lib/offline-auth-bridge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ function setupBridge(
isUnavailableError: overrides.isUnavailableError ?? (() => true),
availability: overrides.availability ?? (() => of(true)),
isIdentityCurrent: overrides.isIdentityCurrent,
beforeRemoteResume: overrides.beforeRemoteResume,
onRemoteResumed: overrides.onRemoteResumed,
resumeMode: overrides.resumeMode,
retryDelayMs: overrides.retryDelayMs,
});

Expand All @@ -84,12 +86,37 @@ describe('createOfflineAuthBridge', () => {
['2'],
'subject-a',
expect.objectContaining({ isCurrent: expect.any(Function) }),
{ deferRuntime: false },
);

await recovery.resume(lease);
expect(order).toEqual(['exchange-authorize', 'prepare', 'resume']);
});

it('waits for the product readiness boundary before resuming transport', async () => {
const order: string[] = [];
const { bridge, offline } = setupBridge({
beforeRemoteResume: async () => void order.push('ready'),
onRemoteResumed: async () => void order.push('resumed'),
resumeMode: 'background',
});
const { lease } = createLease();

const recovery = assertRecovery(await bridge.onAuthorized!(stateStub, lease));
await recovery.activate(lease);
await recovery.resume(lease);

expect(recovery.resumeMode).toBe('background');
expect(offline.prepareRemoteSession).toHaveBeenCalledWith(
1,
['2'],
'subject-a',
expect.objectContaining({ isCurrent: expect.any(Function) }),
{ deferRuntime: true },
);
expect(order).toEqual(['ready', 'resumed']);
});

it('rejects activation when the lease becomes stale after exchange', async () => {
const { lease, invalidate } = createLease();
const { bridge } = setupBridge({
Expand Down
29 changes: 27 additions & 2 deletions projects/kit/offline/src/lib/offline-auth-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export interface CreateOfflineAuthBridgeOptions<TIdentity extends OfflineRemoteI
readonly isIdentityCurrent?: (identity: TIdentity) => boolean;
/** Optional product hook after the kit publishes remote access and transport resumes. */
readonly onRemoteResumed?: (context: OfflineAuthResumeContext<TIdentity>) => Promise<void>;
/** Optional readiness boundary to await after remote access is published and before transport resumes. */
readonly beforeRemoteResume?: (context: OfflineAuthResumeContext<TIdentity>) => Promise<void>;
/** Whether guarded route activation waits for transport resume. Defaults to `blocking`. */
readonly resumeMode?: 'blocking' | 'background';
/** Delay before {@link KitAuthRecoveryService} retries recovery while local access remains active. */
readonly retryDelayMs?: number;
}
Expand Down Expand Up @@ -90,7 +94,16 @@ export function createOfflineAuthBridge<TIdentity extends OfflineRemoteIdentity>
options: CreateOfflineAuthBridgeOptions<TIdentity>,
): OfflineAuthBridgeConfig {
const offline = options.offline ?? inject(OfflineCoordinatorService);
const { exchange, currentAuthSubject, isUnavailableError, isIdentityCurrent, onRemoteResumed, retryDelayMs } = options;
const {
exchange,
currentAuthSubject,
isUnavailableError,
isIdentityCurrent,
beforeRemoteResume,
onRemoteResumed,
resumeMode,
retryDelayMs,
} = options;
const defaultAvailability$ = options.availability
? undefined
: toObservable(offline.networkState).pipe(
Expand All @@ -115,18 +128,30 @@ export function createOfflineAuthBridge<TIdentity extends OfflineRemoteIdentity>
if (!identityStillCurrent(lease, identity)) return false;

return {
resumeMode,
activate: async (activateLease) => {
if (!identityStillCurrent(activateLease, identity)) return false;
const identityLease: KitAuthAccessLease = {
isCurrent: () => identityStillCurrent(activateLease, identity),
};
const prepared = await offline.prepareRemoteSession(identity.userId, identity.scopeIds, identity.authSubject, identityLease);
const prepared = await offline.prepareRemoteSession(identity.userId, identity.scopeIds, identity.authSubject, identityLease, {
deferRuntime: resumeMode === 'background',
});
return prepared && identityLease.isCurrent();
},
resume: async (resumeLease) => {
const resumeStillCurrent = (): boolean =>
(resumeLease?.isCurrent() ?? true) && currentAuthSubject() === identity.authSubject && (isIdentityCurrent?.(identity) ?? true);
if (!resumeStillCurrent()) return;
if (beforeRemoteResume) {
await beforeRemoteResume({
phase,
state,
lease: resumeLease ?? lease,
identity,
});
if (!resumeStillCurrent()) return;
}
await offline.resumeRemoteSession(
identity.foregroundScopeIds !== undefined ? { foregroundScopeIds: identity.foregroundScopeIds } : undefined,
);
Expand Down
12 changes: 5 additions & 7 deletions projects/kit/offline/src/lib/offline-coordinator.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,23 +184,21 @@ describe('OfflineCoordinatorService', () => {
expect(sync.initialize).toHaveBeenCalledOnce();
});

it('does not start a session transition before runtime initialization completes', async () => {
it('prepares a verified session without waiting for remote runtime initialization', async () => {
let releaseNetwork: (() => void) | undefined;
const networkGate = new Promise<void>((resolve) => {
releaseNetwork = resolve;
});
const { coordinator, order, sync } = setup(null, { networkInitialize: () => networkGate });

const activation = coordinator.prepareRemoteSession(1, ['2'], 'subject');
const activation = coordinator.prepareRemoteSession(1, ['2'], 'subject', undefined, { deferRuntime: true });
await coordinator.initializeLocal();

expect(sync.initialize).toHaveBeenCalledOnce();
expect(order).toEqual([]);

releaseNetwork?.();
await expect(activation).resolves.toBe(true);
expect(sync.initialize).toHaveBeenCalledOnce();
expect(sync.initialize).not.toHaveBeenCalled();
expect(order).toEqual(['reset', 'suspend-remote', 'activate-remote']);

releaseNetwork?.();
});

it('does not revive a remote activation invalidated by logout while network initialization waits', async () => {
Expand Down
9 changes: 8 additions & 1 deletion projects/kit/offline/src/lib/offline-coordinator.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export interface OfflineResumeRemoteSessionOptions {
readonly foregroundScopeIds?: readonly string[];
}

/** Startup policy for preparing a remotely verified session boundary. */
export interface OfflinePrepareRemoteSessionOptions {
/** Skip network and sync startup until {@link resumeRemoteSession}. Defaults to `false`. */
readonly deferRuntime?: boolean;
}

/** Coordinates local persistence, session boundaries, network state, and outbox synchronization. */
@Injectable({ providedIn: 'root' })
export class OfflineCoordinatorService {
Expand Down Expand Up @@ -115,10 +121,11 @@ export class OfflineCoordinatorService {
scopeIds: readonly string[],
authSubject: string | null,
authLease?: OfflineSessionTransitionLease,
options: OfflinePrepareRemoteSessionOptions = {},
): Promise<boolean> {
const revision = ++this.#transitionRevision;
const lease = this.#lease(revision, authLease);
await this.initialize();
await (options.deferRuntime ? this.initializeLocal() : this.initialize());
if (this.#storageUnavailable()) return true;
if (!lease.isCurrent()) return false;
return this.#enqueueTransition(async () => {
Expand Down
48 changes: 47 additions & 1 deletion projects/kit/offline/src/lib/offline-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,10 @@ describe('provideOffline', () => {
commandHooks: ParentCommandHooks,
replicaProjector: ParentReplicaProjector,
}),
{ provide: OfflineCoordinatorService, useValue: { initialize: vi.fn(async () => undefined), initializeLocal: vi.fn(async () => undefined) } },
{
provide: OfflineCoordinatorService,
useValue: { initialize: vi.fn(async () => undefined), initializeLocal: vi.fn(async () => undefined) },
},
],
});
const parent = TestBed.inject(EnvironmentInjector);
Expand Down Expand Up @@ -253,4 +256,47 @@ describe('provideOffline', () => {
expect(coordinator.initializeLocal).toHaveBeenCalledOnce();
expect(coordinator.initialize).toHaveBeenCalledOnce();
});

it('can defer route transport initialization while still opening local storage once', async () => {
const coordinator = {
initialize: vi.fn(async () => undefined),
initializeLocal: vi.fn(async () => undefined),
};
TestBed.configureTestingModule({
providers: [
OfflineRouteInitializerService,
{ provide: OfflineCoordinatorService, useValue: coordinator },
{ provide: ErrorHandler, useValue: { handleError: vi.fn() } },
{ provide: OFFLINE_KIT_OPTIONS, useValue: { mode: 'readCacheOnly' } },
],
});

const initializer = TestBed.inject(OfflineRouteInitializerService);
await initializer.initialize({ remote: 'deferred' });

expect(coordinator.initializeLocal).toHaveBeenCalledOnce();
expect(coordinator.initialize).not.toHaveBeenCalled();
});

it('can start network discovery after deferred local initialization', async () => {
const coordinator = {
initialize: vi.fn(async () => undefined),
initializeLocal: vi.fn(async () => undefined),
};
TestBed.configureTestingModule({
providers: [
OfflineRouteInitializerService,
{ provide: OfflineCoordinatorService, useValue: coordinator },
{ provide: ErrorHandler, useValue: { handleError: vi.fn() } },
{ provide: OFFLINE_KIT_OPTIONS, useValue: { mode: 'readCacheOnly' } },
],
});

const initializer = TestBed.inject(OfflineRouteInitializerService);
await initializer.initialize({ remote: 'deferred' });
await initializer.startRemoteRuntime();

expect(coordinator.initializeLocal).toHaveBeenCalledTimes(2);
expect(coordinator.initialize).toHaveBeenCalledOnce();
});
});
14 changes: 10 additions & 4 deletions projects/kit/offline/src/lib/offline-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,21 @@ export class OfflineRouteInitializerService {
readonly #options = inject(OFFLINE_KIT_OPTIONS);
#initialization: Promise<void> | null = null;

/** Initialize local storage once, then continue transport initialization in the background. */
initialize(): Promise<void> {
return (this.#initialization ??= this.#initialize());
/** Initialize local storage once, optionally continuing transport initialization in the background. */
initialize(options: { remote?: 'background' | 'deferred' } = {}): Promise<void> {
return (this.#initialization ??= this.#initialize(options.remote ?? 'background'));
}

#initialize(): Promise<void> {
/** Starts deferred network discovery and synchronization without delaying the caller on transport. */
startRemoteRuntime(): Promise<void> {
assertSupportedOfflineMode(Capacitor.getPlatform(), this.#options.mode ?? 'synchronized');
return initializeOfflineRuntime(this.#coordinator, this.#errorHandler);
}

#initialize(remote: 'background' | 'deferred'): Promise<void> {
assertSupportedOfflineMode(Capacitor.getPlatform(), this.#options.mode ?? 'synchronized');
return remote === 'deferred' ? this.#coordinator.initializeLocal() : initializeOfflineRuntime(this.#coordinator, this.#errorHandler);
}
}

/**
Expand Down
11 changes: 11 additions & 0 deletions projects/kit/src/lib/auth/auth-access.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ export interface KitRemoteAccessRecovery {
* with callers that manually resumed a recovery result before leases were introduced.
*/
resume(lease?: KitAuthAccessLease): Promise<void>;
/**
* Whether route activation must wait for {@link resume} to settle.
*
* @remarks
* Use `background` only when {@link activate} has already installed every identity and local
* capability boundary required to render the route safely. Authentication denial still revokes
* access; transport unavailability is handled by the configured recovery policy.
*
* @defaultValue 'blocking'
*/
resumeMode?: 'blocking' | 'background';
}

/** Recovery-specific authentication configuration consumed by {@link KitAuthRecoveryService}. */
Expand Down
Loading