Files
iptvnator/apps/web/src/app/server-storage/server-instance-probe.ts
T
4grayandClaude Opus 5.5 676e3adcd4 fix(server-storage): keep Xtream data, ordered sync, sign-in and stale-cache fixes
- Moving browser playlists matches them to the server's by id or source and
  carries the legacy Xtream maps under the server id; it no longer goes
  through the backup format, whose PWA export has no Xtream user data. A
  partial move stays on offer.
- User-data batches go out in order, and a re-read keeps entries edited
  while it was in flight.
- A keepPayload write keeps its cached copy only when the server replaced
  the version its payload came from (new X-Replaced-ETag header).
- 502/503/504 from the proxy count as offline.
- The start-up screen lets the sign-in page through while a token is
  required.
- Settings → Server offers a reload after a move.
- The probe detects Electron through RuntimeCapabilitiesService.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:16:56 +02:00

83 lines
3.0 KiB
TypeScript

import { RuntimeCapabilitiesService } from '@iptvnator/services';
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
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;
readonly fetchFn?: typeof fetch;
}
/**
* Asks the backend whether it offers server storage. Runs only when the
* deployment wrote a runtime `BACKEND_URL` (the Docker entrypoint does), so
* hosted builds never call an external parser API for it.
*/
export async function probeServerInstance(
options: ServerInstanceProbeOptions = {}
): Promise<ServerInstanceProbe> {
const runtimeConfig =
options.runtimeConfig ?? globalThis.window?.__IPTVNATOR_CONFIG__;
// The capability service has no dependencies, so it works before
// bootstrap and keeps one definition of the desktop runtime.
const isElectron =
options.isElectron ?? new RuntimeCapabilitiesService().isElectron;
const baseUrl = runtimeConfig?.BACKEND_URL?.trim();
if (isElectron || !baseUrl) {
return { kind: 'skipped' };
}
let response: Response;
try {
const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
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'
? { kind: 'server', instance: body }
: { kind: 'none' };
} catch {
return { kind: 'none' };
}
}
function isServerInstance(value: unknown): value is ServerInstanceInfo {
const record = value as Partial<ServerInstanceInfo> | null;
return (
!!record &&
record.service === 'iptvnator' &&
typeof record.apiVersion === 'number' &&
typeof record.instanceId === 'string' &&
(record.storage === 'server' || record.storage === 'none') &&
(record.auth === 'none' || record.auth === 'token')
);
}