Files
iptvnator/libs/services/src/lib/server-storage/server-api-client.ts
T
4grayandClaude Opus 5.5 72c84a3eba fix(server-storage): keep URL favorites, flush before moving, cache-full and cut-off bodies
- Moving M3U favorites keeps URL favorites and anything the server cannot
  resolve; only older channel ids are translated.
- Queued user-data edits are sent before the move writes combined values.
- A re-read that does not fit the cache keeps the old cache and baseline
  together.
- A response body cut off after its headers counts as offline.

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

197 lines
5.9 KiB
TypeScript

import type { ServerStorageErrorBody } from '@iptvnator/shared/interfaces';
import {
clearServerAccessToken,
serverAuthorizationHeaders,
} from '../server-access-token';
import type { ServerStorageStatusService } from '../server-storage-status.service';
const REQUEST_TIMEOUT_MS = 20_000;
/** nginx answers these while the backend behind it is down. */
const GATEWAY_UNAVAILABLE = new Set([502, 503, 504]);
export class ServerApiError extends Error {
constructor(
readonly status: number,
readonly code: string | undefined,
message: string
) {
super(message);
this.name = 'ServerApiError';
}
}
export interface ServerApiClientOptions {
readonly baseUrl: string;
readonly status: Pick<
ServerStorageStatusService,
'reportSuccess' | 'reportOffline' | 'reportUnauthorized'
>;
/** Failed writes the user should hear about: network, 413, 5xx. */
readonly onWriteFailure?: (error: ServerApiError) => void;
readonly fetchFn?: typeof fetch;
readonly timeoutMs?: number;
}
/** `fetch` for `/v1`: token, timeout, error mapping, connection state. */
export class ServerApiClient {
private readonly root: string;
constructor(private readonly options: ServerApiClientOptions) {
this.root = `${options.baseUrl.replace(/\/+$/, '')}/v1`;
}
url(path: string): string {
return `${this.root}${path}`;
}
async request(path: string, init: RequestInit = {}): Promise<Response> {
const isWrite = (init.method ?? 'GET') !== 'GET';
let response: Response;
try {
response = await this.fetchFn()(this.url(path), {
...init,
cache: 'no-store',
headers: {
...serverAuthorizationHeaders(),
...(init.headers as Record<string, string> | undefined),
},
signal: init.signal ?? this.timeoutSignal(),
});
} catch {
this.options.status.reportOffline();
const error = new ServerApiError(
0,
'network',
'The server is not reachable.'
);
if (isWrite) {
this.options.onWriteFailure?.(error);
}
throw error;
}
if (response.status === 401) {
clearServerAccessToken();
this.options.status.reportUnauthorized();
throw new ServerApiError(
401,
'unauthorized',
'The server needs an access token.'
);
}
if (GATEWAY_UNAVAILABLE.has(response.status)) {
// A proxy in front of a backend that is down or restarting.
this.options.status.reportOffline();
}
if (!response.ok && response.status !== 304) {
const body = await readErrorBody(response);
const error = new ServerApiError(
response.status,
body.code,
body.message
);
if (
isWrite &&
(response.status === 413 || response.status >= 500)
) {
this.options.onWriteFailure?.(error);
}
throw error;
}
this.options.status.reportSuccess();
return response;
}
async getJson<T>(path: string): Promise<T> {
return this.readJson<T>(await this.request(path));
}
/**
* A body cut off after the headers arrived is an outage too; malformed
* JSON is not.
*/
async readJson<T>(response: Response): Promise<T> {
try {
return (await response.json()) as T;
} catch (error) {
if (error instanceof SyntaxError) {
throw error;
}
this.options.status.reportOffline();
throw new ServerApiError(
0,
'network',
'The server is not reachable.'
);
}
}
sendJson(
method: 'PUT' | 'POST',
path: string,
body: unknown
): Promise<Response> {
return this.request(path, {
method,
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
}
/** Best effort while the page is hidden or closed; errors are ignored. */
/**
* A write that survives the page. Resolves whether the server took it;
* browsers reject keepalive bodies above 64 KiB, which resolves false.
*/
async sendKeepalive(
method: 'PUT' | 'DELETE',
path: string,
body?: unknown
): Promise<boolean> {
try {
const response = await this.fetchFn()(this.url(path), {
method,
keepalive: true,
headers: {
...serverAuthorizationHeaders(),
...(body === undefined
? {}
: { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return response.ok;
} catch {
return false;
}
}
private fetchFn(): typeof fetch {
return this.options.fetchFn ?? globalThis.fetch.bind(globalThis);
}
private timeoutSignal(): AbortSignal | undefined {
return typeof AbortSignal.timeout === 'function'
? AbortSignal.timeout(this.options.timeoutMs ?? REQUEST_TIMEOUT_MS)
: undefined;
}
}
async function readErrorBody(
response: Response
): Promise<{ message: string; code?: string }> {
try {
const body = (await response.json()) as Partial<ServerStorageErrorBody>;
return {
message: body.message ?? response.statusText,
code: body.code,
};
} catch {
return { message: response.statusText };
}
}