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:
4grayandClaude Opus 5.5 committed 2026-10-12 00:16:56 +02:00
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
View File
@@ -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),
});
}