From 3bfd5394ef85cc092c16dc7a46ce99e5c79ee68d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 11 Oct 2026 23:42:29 +0200 Subject: [PATCH] fix(pwa): stay in server mode when a known server does not answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../server-instance-probe.spec.ts | 85 +++++++++-------- .../server-storage/server-instance-probe.ts | 52 +++++++---- .../server-storage-start.spec.ts | 74 +++++++++++++++ .../server-storage/server-storage-start.ts | 93 +++++++++++++++++++ .../server-storage.providers.ts | 13 ++- .../settings-server-section.component.spec.ts | 8 ++ .../settings-server-section.component.ts | 12 ++- apps/web/src/main.ts | 16 +++- docs/architecture/pwa-self-hosted.md | 8 +- .../specs/2026-10-08-server-storage-design.md | 2 +- .../server-storage-session.spec.ts | 46 ++++++++- .../server-storage/server-storage-session.ts | 12 +++ .../server-storage/server-storage.config.ts | 5 + 13 files changed, 357 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/app/server-storage/server-storage-start.spec.ts create mode 100644 apps/web/src/app/server-storage/server-storage-start.ts diff --git a/apps/web/src/app/server-storage/server-instance-probe.spec.ts b/apps/web/src/app/server-storage/server-instance-probe.spec.ts index 2fd8e3bee..9add42ae5 100644 --- a/apps/web/src/app/server-storage/server-instance-probe.spec.ts +++ b/apps/web/src/app/server-storage/server-instance-probe.spec.ts @@ -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' }); + } }); }); diff --git a/apps/web/src/app/server-storage/server-instance-probe.ts b/apps/web/src/app/server-storage/server-instance-probe.ts index ff5e2c236..20e6e9bc6 100644 --- a/apps/web/src/app/server-storage/server-instance-probe.ts +++ b/apps/web/src/app/server-storage/server-instance-probe.ts @@ -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 { +): Promise { 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' }; } } diff --git a/apps/web/src/app/server-storage/server-storage-start.spec.ts b/apps/web/src/app/server-storage/server-storage-start.spec.ts new file mode 100644 index 000000000..bcaac3a40 --- /dev/null +++ b/apps/web/src/app/server-storage/server-storage-start.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/src/app/server-storage/server-storage-start.ts b/apps/web/src/app/server-storage/server-storage-start.ts new file mode 100644 index 000000000..4b0e8fdb8 --- /dev/null +++ b/apps/web/src/app/server-storage/server-storage-start.ts @@ -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 | 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 | 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 | null): void { + try { + storage?.removeItem(REMEMBERED_SERVER_KEY); + } catch { + // Nothing stored. + } +} + +function safeLocalStorage(): Storage | null { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} diff --git a/apps/web/src/app/server-storage/server-storage.providers.ts b/apps/web/src/app/server-storage/server-storage.providers.ts index 0021017a9..d70327df8 100644 --- a/apps/web/src/app/server-storage/server-storage.providers.ts +++ b/apps/web/src/app/server-storage/server-storage.providers.ts @@ -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, diff --git a/apps/web/src/app/settings/settings-server-section.component.spec.ts b/apps/web/src/app/settings/settings-server-section.component.spec.ts index 03d38eada..9fdfd8d6a 100644 --- a/apps/web/src/app/settings/settings-server-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-server-section.component.spec.ts @@ -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(); diff --git a/apps/web/src/app/settings/settings-server-section.component.ts b/apps/web/src/app/settings/settings-server-section.component.ts index ef04d58bc..5e2c54173 100644 --- a/apps/web/src/app/settings/settings-server-section.component.ts +++ b/apps/web/src/app/settings/settings-server-section.component.ts @@ -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(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`; } diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 927fb9c43..6ff4dd907 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -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 { - 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), }); } diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 9bf7f4e51..e2482857b 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -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, diff --git a/docs/superpowers/specs/2026-10-08-server-storage-design.md b/docs/superpowers/specs/2026-10-08-server-storage-design.md index 75f928dd9..4a5619f49 100644 --- a/docs/superpowers/specs/2026-10-08-server-storage-design.md +++ b/docs/superpowers/specs/2026-10-08-server-storage-design.md @@ -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. | diff --git a/libs/services/src/lib/server-storage/server-storage-session.spec.ts b/libs/services/src/lib/server-storage/server-storage-session.spec.ts index 955c1ec9a..41fa1e5f4 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.spec.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.spec.ts @@ -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(); + }); + }); }); diff --git a/libs/services/src/lib/server-storage/server-storage-session.ts b/libs/services/src/lib/server-storage/server-storage-session.ts index 83138d69e..d964c28a2 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.ts @@ -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 diff --git a/libs/services/src/lib/server-storage/server-storage.config.ts b/libs/services/src/lib/server-storage/server-storage.config.ts index 624d20bbe..fdeb8ce18 100644 --- a/libs/services/src/lib/server-storage/server-storage.config.ts +++ b/libs/services/src/lib/server-storage/server-storage.config.ts @@ -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(