mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(pwa): stay in server mode when a known server does not answer
A failed start-up probe used to fall back to browser mode, which showed an empty library while the backend restarted and let new playlists land in the browser. The browser now remembers the server; without an answer it starts in server mode, offline, and reloads once the server is back. Settings → Server shows the backend address instead of the page's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8714b3b445
commit
3bfd5394ef
13 files changed
+357
-69
No files matched your search
@@ -9,74 +9,79 @@ const instance = {
|
||||
};
|
||||
|
||||
describe('probeServerInstance', () => {
|
||||
const ok = (body: unknown) =>
|
||||
jest.fn().mockResolvedValue({ ok: true, json: async () => body });
|
||||
const answer = (body: unknown, status = 200) =>
|
||||
jest.fn().mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
const config = {
|
||||
runtimeConfig: { BACKEND_URL: '/api' },
|
||||
isElectron: false,
|
||||
};
|
||||
|
||||
it('skips the probe without a runtime backend URL', async () => {
|
||||
const fetchFn = ok(instance);
|
||||
const fetchFn = answer(instance);
|
||||
await expect(
|
||||
probeServerInstance({
|
||||
runtimeConfig: {},
|
||||
fetchFn: fetchFn as never,
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
).resolves.toEqual({ kind: 'skipped' });
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the probe in Electron', async () => {
|
||||
const fetchFn = ok(instance);
|
||||
const fetchFn = answer(instance);
|
||||
await expect(
|
||||
probeServerInstance({
|
||||
runtimeConfig: { BACKEND_URL: '/api' },
|
||||
...config,
|
||||
isElectron: true,
|
||||
fetchFn: fetchFn as never,
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
).resolves.toEqual({ kind: 'skipped' });
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the instance when it reports server storage', async () => {
|
||||
const fetchFn = ok(instance);
|
||||
const fetchFn = answer(instance);
|
||||
await expect(
|
||||
probeServerInstance({
|
||||
runtimeConfig: { BACKEND_URL: '/api/' },
|
||||
isElectron: false,
|
||||
fetchFn: fetchFn as never,
|
||||
})
|
||||
).resolves.toEqual(instance);
|
||||
).resolves.toEqual({ kind: 'server', instance });
|
||||
expect(fetchFn.mock.calls[0][0]).toBe('/api/v1/instance');
|
||||
});
|
||||
|
||||
it('returns null for storage none, bad bodies and failures', async () => {
|
||||
const config = {
|
||||
runtimeConfig: { BACKEND_URL: '/api' },
|
||||
isElectron: false,
|
||||
};
|
||||
await expect(
|
||||
probeServerInstance({
|
||||
...config,
|
||||
fetchFn: ok({ ...instance, storage: 'none' }) as never,
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
probeServerInstance({
|
||||
...config,
|
||||
fetchFn: ok({ hello: 1 }) as never,
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
probeServerInstance({
|
||||
...config,
|
||||
fetchFn: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 404 }) as never,
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
probeServerInstance({
|
||||
...config,
|
||||
fetchFn: jest.fn().mockRejectedValue(new Error('x')) as never,
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
it('reports no server storage for an answer without it', async () => {
|
||||
for (const fetchFn of [
|
||||
answer({ ...instance, storage: 'none' }),
|
||||
answer({ hello: 1 }),
|
||||
answer({ error: 'not found' }, 404),
|
||||
jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError('not json');
|
||||
},
|
||||
}),
|
||||
]) {
|
||||
await expect(
|
||||
probeServerInstance({ ...config, fetchFn: fetchFn as never })
|
||||
).resolves.toEqual({ kind: 'none' });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports an unreachable server for failures and 5xx', async () => {
|
||||
for (const fetchFn of [
|
||||
jest.fn().mockRejectedValue(new TypeError('Failed to fetch')),
|
||||
answer('Bad Gateway', 502),
|
||||
]) {
|
||||
await expect(
|
||||
probeServerInstance({ ...config, fetchFn: fetchFn as never })
|
||||
).resolves.toEqual({ kind: 'unreachable' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,16 @@ import type { IptvnatorRuntimeConfig } from '../services/runtime-config';
|
||||
|
||||
const PROBE_TIMEOUT_MS = 2_500;
|
||||
|
||||
/**
|
||||
* - `skipped`: no runtime backend URL, or Electron.
|
||||
* - `server` / `none`: the backend answered.
|
||||
* - `unreachable`: no answer, a timeout or a 5xx (nginx answers 502 while
|
||||
* the backend restarts), so the result says nothing about storage.
|
||||
*/
|
||||
export type ServerInstanceProbe =
|
||||
| { readonly kind: 'skipped' | 'none' | 'unreachable' }
|
||||
| { readonly kind: 'server'; readonly instance: ServerInstanceInfo };
|
||||
|
||||
export interface ServerInstanceProbeOptions {
|
||||
readonly runtimeConfig?: IptvnatorRuntimeConfig;
|
||||
readonly isElectron?: boolean;
|
||||
@@ -16,36 +26,42 @@ export interface ServerInstanceProbeOptions {
|
||||
*/
|
||||
export async function probeServerInstance(
|
||||
options: ServerInstanceProbeOptions = {}
|
||||
): Promise<ServerInstanceInfo | null> {
|
||||
): Promise<ServerInstanceProbe> {
|
||||
const runtimeConfig =
|
||||
options.runtimeConfig ?? globalThis.window?.__IPTVNATOR_CONFIG__;
|
||||
const isElectron = options.isElectron ?? !!globalThis.window?.electron;
|
||||
const baseUrl = runtimeConfig?.BACKEND_URL?.trim();
|
||||
if (isElectron || !baseUrl) {
|
||||
return null;
|
||||
return { kind: 'skipped' };
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
|
||||
const response = await fetchFn(
|
||||
`${baseUrl.replace(/\/+$/, '')}/v1/instance`,
|
||||
{
|
||||
cache: 'no-store',
|
||||
signal:
|
||||
typeof AbortSignal.timeout === 'function'
|
||||
? AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
response = await fetchFn(`${baseUrl.replace(/\/+$/, '')}/v1/instance`, {
|
||||
cache: 'no-store',
|
||||
signal:
|
||||
typeof AbortSignal.timeout === 'function'
|
||||
? AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
||||
: undefined,
|
||||
});
|
||||
} catch {
|
||||
return { kind: 'unreachable' };
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
return { kind: 'unreachable' };
|
||||
}
|
||||
if (!response.ok) {
|
||||
// A backend from before server storage has no /v1 routes.
|
||||
return { kind: 'none' };
|
||||
}
|
||||
try {
|
||||
const body: unknown = await response.json();
|
||||
return isServerInstance(body) && body.storage === 'server'
|
||||
? body
|
||||
: null;
|
||||
? { kind: 'server', instance: body }
|
||||
: { kind: 'none' };
|
||||
} catch {
|
||||
return null;
|
||||
return { kind: 'none' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
REMEMBERED_SERVER_KEY,
|
||||
resolveServerStorageStart,
|
||||
} from './server-storage-start';
|
||||
|
||||
const instance: ServerInstanceInfo = {
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i-1',
|
||||
storage: 'server',
|
||||
auth: 'none',
|
||||
};
|
||||
|
||||
describe('resolveServerStorageStart', () => {
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('starts online and remembers a server that answered', () => {
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api')
|
||||
).toEqual({ instance, startedOffline: false });
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(REMEMBERED_SERVER_KEY) ?? '')
|
||||
).toEqual({ baseUrl: '/api', instance });
|
||||
});
|
||||
|
||||
it('starts offline when a remembered server does not answer', () => {
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api');
|
||||
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'unreachable' }, '/api')
|
||||
).toEqual({ instance, startedOffline: true });
|
||||
});
|
||||
|
||||
it('uses browser mode for an unknown or different server', () => {
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'unreachable' }, '/api')
|
||||
).toBeNull();
|
||||
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api');
|
||||
expect(
|
||||
resolveServerStorageStart(
|
||||
{ kind: 'unreachable' },
|
||||
'http://other:3000'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('forgets the server once it answers without server storage', () => {
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api');
|
||||
|
||||
expect(resolveServerStorageStart({ kind: 'none' }, '/api')).toBeNull();
|
||||
expect(localStorage.getItem(REMEMBERED_SERVER_KEY)).toBeNull();
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'unreachable' }, '/api')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a skipped probe and unreadable memory', () => {
|
||||
resolveServerStorageStart({ kind: 'server', instance }, '/api');
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'skipped' }, '/api')
|
||||
).toBeNull();
|
||||
expect(localStorage.getItem(REMEMBERED_SERVER_KEY)).not.toBeNull();
|
||||
|
||||
localStorage.setItem(REMEMBERED_SERVER_KEY, '{broken');
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'unreachable' }, '/api')
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveServerStorageStart({ kind: 'unreachable' }, '/api', null)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
import type { ServerInstanceProbe } from './server-instance-probe';
|
||||
|
||||
/** Last instance this browser saw, per backend URL. */
|
||||
export const REMEMBERED_SERVER_KEY = 'iptvnator-server:instance';
|
||||
|
||||
export interface ServerStorageStart {
|
||||
readonly instance: ServerInstanceInfo;
|
||||
/** No answer at start; the instance comes from this browser's memory. */
|
||||
readonly startedOffline: boolean;
|
||||
}
|
||||
|
||||
interface RememberedServer {
|
||||
readonly baseUrl: string;
|
||||
readonly instance: ServerInstanceInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether the app starts in server mode. A browser that has used
|
||||
* this server before stays in server mode while it does not answer (shown
|
||||
* as offline), so it never falls back to an empty browser library and
|
||||
* collects playlists the server will not see. An answer without server
|
||||
* storage forgets the server.
|
||||
*/
|
||||
export function resolveServerStorageStart(
|
||||
probe: ServerInstanceProbe,
|
||||
baseUrl: string,
|
||||
storage: Pick<
|
||||
Storage,
|
||||
'getItem' | 'setItem' | 'removeItem'
|
||||
> | null = safeLocalStorage()
|
||||
): ServerStorageStart | null {
|
||||
switch (probe.kind) {
|
||||
case 'server':
|
||||
write(storage, { baseUrl, instance: probe.instance });
|
||||
return { instance: probe.instance, startedOffline: false };
|
||||
case 'none':
|
||||
remove(storage);
|
||||
return null;
|
||||
case 'unreachable': {
|
||||
const remembered = read(storage);
|
||||
return remembered?.baseUrl === baseUrl
|
||||
? { instance: remembered.instance, startedOffline: true }
|
||||
: null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function read(
|
||||
storage: Pick<Storage, 'getItem'> | null
|
||||
): RememberedServer | null {
|
||||
try {
|
||||
const raw = storage?.getItem(REMEMBERED_SERVER_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as RememberedServer) : null;
|
||||
return parsed &&
|
||||
typeof parsed.baseUrl === 'string' &&
|
||||
parsed.instance?.storage === 'server' &&
|
||||
typeof parsed.instance.instanceId === 'string'
|
||||
? parsed
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(
|
||||
storage: Pick<Storage, 'setItem'> | null,
|
||||
value: RememberedServer
|
||||
): void {
|
||||
try {
|
||||
storage?.setItem(REMEMBERED_SERVER_KEY, JSON.stringify(value));
|
||||
} catch {
|
||||
// Without storage an offline start falls back to browser mode.
|
||||
}
|
||||
}
|
||||
|
||||
function remove(storage: Pick<Storage, 'removeItem'> | null): void {
|
||||
try {
|
||||
storage?.removeItem(REMEMBERED_SERVER_KEY);
|
||||
} catch {
|
||||
// Nothing stored.
|
||||
}
|
||||
}
|
||||
|
||||
function safeLocalStorage(): Storage | null {
|
||||
try {
|
||||
return globalThis.localStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,23 @@ import {
|
||||
ServerAccessTokenInterceptor,
|
||||
ServerStorageSession,
|
||||
} from '@iptvnator/services/server-storage';
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
import { ServerMigrationPrompt } from './server-migration-prompt.service';
|
||||
import type { ServerStorageStart } from './server-storage-start';
|
||||
|
||||
/** Loaded with a dynamic import from `main.ts`, only in server mode. */
|
||||
export function provideServerStorage(
|
||||
instance: ServerInstanceInfo,
|
||||
start: ServerStorageStart,
|
||||
baseUrl: string
|
||||
): (Provider | EnvironmentProviders)[] {
|
||||
return [
|
||||
{ provide: SERVER_STORAGE_CONFIG, useValue: { instance, baseUrl } },
|
||||
{
|
||||
provide: SERVER_STORAGE_CONFIG,
|
||||
useValue: {
|
||||
instance: start.instance,
|
||||
startedOffline: start.startedOffline,
|
||||
baseUrl,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: USER_DATA_STORAGE,
|
||||
useFactory: () => inject(ServerStorageSession).userData,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ServerStorageStatusService } from '@iptvnator/services';
|
||||
import {
|
||||
SERVER_STORAGE_CONFIG,
|
||||
ServerMigrationService,
|
||||
ServerStorageSession,
|
||||
} from '@iptvnator/services/server-storage';
|
||||
@@ -37,6 +38,10 @@ describe('SettingsServerSectionComponent', () => {
|
||||
providers: [
|
||||
{ provide: ServerStorageSession, useValue: session },
|
||||
{ provide: ServerMigrationService, useValue: migration },
|
||||
{
|
||||
provide: SERVER_STORAGE_CONFIG,
|
||||
useValue: { baseUrl: 'http://nas.local:4333/api' },
|
||||
},
|
||||
{
|
||||
provide: ServerStorageStatusService,
|
||||
useValue: {
|
||||
@@ -64,6 +69,9 @@ describe('SettingsServerSectionComponent', () => {
|
||||
const text = fixture.nativeElement.textContent;
|
||||
|
||||
expect(text).toContain('/data/databases/iptvnator.db');
|
||||
expect(byTestId('server-address')?.textContent?.trim()).toBe(
|
||||
'http://nas.local:4333'
|
||||
);
|
||||
expect(byTestId('server-state')?.dataset['state']).toBe('online');
|
||||
expect(fixture.componentInstance.sizeLabel()).toBe('38.0 MB');
|
||||
expect(byTestId('server-sign-out')).toBeNull();
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ServerStorageStatusService } from '@iptvnator/services';
|
||||
import {
|
||||
SERVER_STORAGE_CONFIG,
|
||||
ServerMigrationService,
|
||||
ServerStorageSession,
|
||||
type ServerMigrationResult,
|
||||
@@ -47,7 +48,8 @@ export class SettingsServerSectionComponent {
|
||||
readonly usesToken = computed(
|
||||
() => this.status.instance()?.auth === 'token'
|
||||
);
|
||||
readonly address = globalThis.location?.origin ?? '';
|
||||
/** The backend's origin; the page's own in Docker (`/api`). */
|
||||
readonly address = backendOrigin(inject(SERVER_STORAGE_CONFIG).baseUrl);
|
||||
readonly details = signal<ServerStorageStatusResponse | null>(null);
|
||||
readonly browserPlaylistCount = signal(0);
|
||||
readonly importing = signal(false);
|
||||
@@ -99,6 +101,14 @@ export class SettingsServerSectionComponent {
|
||||
}
|
||||
}
|
||||
|
||||
function backendOrigin(baseUrl: string): string {
|
||||
try {
|
||||
return new URL(baseUrl, globalThis.location?.href).origin;
|
||||
} catch {
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMegabytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
+12
-4
@@ -5,6 +5,7 @@ import { registerAppDateLocale } from './app/app-date-locales';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import { appConfig, getInitialLanguage } from './app/app.config';
|
||||
import { probeServerInstance } from './app/server-storage/server-instance-probe';
|
||||
import { resolveServerStorageStart } from './app/server-storage/server-storage-start';
|
||||
import { getRuntimeBackendUrl } from './app/services/runtime-config';
|
||||
|
||||
// A reloaded packaged renderer arrives on index.html with the route it was
|
||||
@@ -19,16 +20,23 @@ if (restoredHref !== null) {
|
||||
window.history.replaceState(window.history.state, '', restoredHref);
|
||||
}
|
||||
|
||||
/** Adds server storage when the self-hosted backend offers it. */
|
||||
/**
|
||||
* Adds server storage when the self-hosted backend offers it, or when this
|
||||
* browser has used it before and it does not answer right now.
|
||||
*/
|
||||
async function resolveAppConfig(): Promise<ApplicationConfig> {
|
||||
const instance = await probeServerInstance();
|
||||
if (!instance) {
|
||||
const baseUrl = getRuntimeBackendUrl();
|
||||
const start = resolveServerStorageStart(
|
||||
await probeServerInstance(),
|
||||
baseUrl
|
||||
);
|
||||
if (!start) {
|
||||
return appConfig;
|
||||
}
|
||||
const { provideServerStorage } =
|
||||
await import('./app/server-storage/server-storage.providers');
|
||||
return mergeApplicationConfig(appConfig, {
|
||||
providers: provideServerStorage(instance, getRuntimeBackendUrl()),
|
||||
providers: provideServerStorage(start, baseUrl),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,13 @@ expose desktop-only actions in the PWA/shared UI.
|
||||
deployment wrote a runtime `BACKEND_URL` into `window.__IPTVNATOR_CONFIG__`
|
||||
(the Docker entrypoint does; hosted builds and Electron never probe). On
|
||||
`storage: 'server'` it imports `apps/web/src/app/server-storage/` lazily and
|
||||
merges `provideServerStorage()` into the app config. Everything else of the
|
||||
merges `provideServerStorage()` into the app config, and remembers the
|
||||
instance under `iptvnator-server:instance`. When the probe gets no answer
|
||||
(network error, timeout, or a 5xx such as nginx's 502 while the backend
|
||||
restarts), a browser that remembers this backend still starts in server
|
||||
mode, offline, and reloads once the server answers; it never falls back to
|
||||
an empty browser library. An answer without server storage, or a 404 from
|
||||
an older backend, forgets it (`server-storage-start.ts`). Everything else of the
|
||||
client lives behind the `@iptvnator/services/server-storage` sub-entry, so
|
||||
other builds never load it; `ServerStorageStatusService` and the token
|
||||
helpers stay in the main `@iptvnator/services` barrel because the rail,
|
||||
|
||||
@@ -170,7 +170,7 @@ https://claude.ai/artifact/3WheJAgeNL5ag6kmtPrJcD
|
||||
|
||||
| Situation | Behavior |
|
||||
| --- | --- |
|
||||
| Probe fails or times out (2.5 s) | Browser mode, no server UI. |
|
||||
| Probe gets no answer (network error, 2.5 s timeout, 5xx) | Server mode, offline, if this browser used this backend before (`iptvnator-server:instance`); the page reloads once the server answers. Otherwise browser mode. An answer without server storage, or a 404, forgets the server. |
|
||||
| Request fails after boot | Status `offline`, banner, write failures show a snackbar; retry on `visibilitychange` and every 30 s. |
|
||||
| `401` | Status `unauthorized`, stored token cleared, redirect to sign-in. |
|
||||
| `413` | Snackbar names the limit. |
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('ServerStorageSession', () => {
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
function setup(auth: 'none' | 'token') {
|
||||
function setup(auth: 'none' | 'token', startedOffline = false) {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([{ path: 'server-sign-in', children: [] }]),
|
||||
@@ -49,6 +49,7 @@ describe('ServerStorageSession', () => {
|
||||
provide: SERVER_STORAGE_CONFIG,
|
||||
useValue: {
|
||||
baseUrl: '/api',
|
||||
startedOffline,
|
||||
instance: {
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
@@ -97,4 +98,47 @@ describe('ServerStorageSession', () => {
|
||||
|
||||
expect(status.state()).toBe('online');
|
||||
});
|
||||
|
||||
describe('after an offline start', () => {
|
||||
it('stays offline until the server answers, then reloads once', async () => {
|
||||
const { session, status } = setup('none', true);
|
||||
const reload = jest
|
||||
.spyOn(
|
||||
session as unknown as { reloadPage(): void },
|
||||
'reloadPage'
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
|
||||
await session.start();
|
||||
expect(status.state()).toBe('offline');
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ entries: {} }),
|
||||
});
|
||||
await session.refresh();
|
||||
await session.refresh();
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never reloads after a normal start', async () => {
|
||||
const { session } = setup('none');
|
||||
const reload = jest
|
||||
.spyOn(
|
||||
session as unknown as { reloadPage(): void },
|
||||
'reloadPage'
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await session.start();
|
||||
await session.refresh();
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ export class ServerStorageSession {
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly router = inject(Router);
|
||||
private reloading = false;
|
||||
|
||||
readonly api = new ServerApiClient({
|
||||
baseUrl: this.config.baseUrl,
|
||||
@@ -75,6 +76,12 @@ export class ServerStorageSession {
|
||||
await this.userData.hydrate();
|
||||
} catch {
|
||||
// The API client already reported offline or unauthorized.
|
||||
return;
|
||||
}
|
||||
if (this.config.startedOffline && !this.reloading) {
|
||||
// Nothing was read at start: boot again against the server.
|
||||
this.reloading = true;
|
||||
this.reloadPage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +114,11 @@ export class ServerStorageSession {
|
||||
}, RETRY_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Separate so tests can observe it. */
|
||||
protected reloadPage(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
private announceWriteFailure(error?: ServerApiError): void {
|
||||
const key =
|
||||
error?.status === 413
|
||||
|
||||
@@ -5,6 +5,11 @@ export interface ServerStorageConfig {
|
||||
readonly instance: ServerInstanceInfo;
|
||||
/** Backend base URL as the PWA resolves it, e.g. `/api`. */
|
||||
readonly baseUrl: string;
|
||||
/**
|
||||
* The start-up probe got no answer and the instance came from this
|
||||
* browser's memory of the server: the page reloads once it answers.
|
||||
*/
|
||||
readonly startedOffline?: boolean;
|
||||
}
|
||||
|
||||
export const SERVER_STORAGE_CONFIG = new InjectionToken<ServerStorageConfig>(
|
||||
|
||||
Reference in new issue
Block a user