feat(services): server storage API client

The @iptvnator/services/server-storage sub-entry keeps server mode out of
the eager services barrel.

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:55 +02:00
1 parent ab2eef26d2
commit b74584fdb2
5 files changed
+274

No files matched your search

@@ -0,0 +1,2 @@
export * from './server-storage.config';
export * from './server-api-client';
@@ -0,0 +1,98 @@
import { SERVER_ACCESS_TOKEN_STORAGE_KEY } from '../server-access-token';
import { ServerApiClient, ServerApiError } from './server-api-client';
// jsdom has Headers but no Response or fetch.
function response(status: number, body?: unknown) {
return {
status,
ok: status >= 200 && status < 300,
statusText: '',
headers: new Headers(),
json: async () => body,
} as unknown as Response;
}
describe('ServerApiClient', () => {
const status = {
reportSuccess: jest.fn(),
reportOffline: jest.fn(),
reportUnauthorized: jest.fn(),
};
const onWriteFailure = jest.fn();
afterEach(() => {
jest.clearAllMocks();
localStorage.clear();
});
function client(fetchFn: jest.Mock) {
return new ServerApiClient({
baseUrl: '/api/',
status,
onWriteFailure,
fetchFn: fetchFn as unknown as typeof fetch,
});
}
it('calls /v1 with the token and reports success', async () => {
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 't');
const fetchFn = jest
.fn()
.mockResolvedValue(response(200, { ok: true }));
await expect(client(fetchFn).getJson('/instance')).resolves.toEqual({
ok: true,
});
const [url, init] = fetchFn.mock.calls[0];
expect(url).toBe('/api/v1/instance');
expect(init.headers).toMatchObject({ Authorization: 'Bearer t' });
expect(status.reportSuccess).toHaveBeenCalled();
});
it('reports offline on a network error and announces failed writes', async () => {
const fetchFn = jest
.fn()
.mockRejectedValue(new TypeError('Failed to fetch'));
await expect(
client(fetchFn).sendJson('PUT', '/playlists/a', {})
).rejects.toMatchObject({ status: 0, code: 'network' });
expect(status.reportOffline).toHaveBeenCalled();
expect(onWriteFailure).toHaveBeenCalledTimes(1);
});
it('clears the token and reports unauthorized on 401', async () => {
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'old');
const fetchFn = jest
.fn()
.mockResolvedValue(response(401, { code: 'unauthorized' }));
await expect(
client(fetchFn).getJson('/playlists')
).rejects.toBeInstanceOf(ServerApiError);
expect(
localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)
).toBeNull();
expect(status.reportUnauthorized).toHaveBeenCalled();
});
it('passes 304 through and maps error bodies', async () => {
const fetchFn = jest
.fn()
.mockResolvedValueOnce(response(304))
.mockResolvedValueOnce(
response(409, { message: 'x', code: 'payload_missing' })
);
const api = client(fetchFn);
expect((await api.request('/playlists/a')).status).toBe(304);
await expect(
api.sendJson('PUT', '/playlists/a', {})
).rejects.toMatchObject({
status: 409,
code: 'payload_missing',
});
expect(onWriteFailure).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,159 @@
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;
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 (!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 (await this.request(path)).json() as Promise<T>;
}
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. */
sendKeepalive(
method: 'PUT' | 'DELETE',
path: string,
body?: unknown
): void {
void this.fetchFn()(this.url(path), {
method,
keepalive: true,
headers: {
...serverAuthorizationHeaders(),
...(body === undefined
? {}
: { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
}).catch(() => undefined);
}
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 };
}
}
@@ -0,0 +1,12 @@
import { InjectionToken } from '@angular/core';
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
export interface ServerStorageConfig {
readonly instance: ServerInstanceInfo;
/** Backend base URL as the PWA resolves it, e.g. `/api`. */
readonly baseUrl: string;
}
export const SERVER_STORAGE_CONFIG = new InjectionToken<ServerStorageConfig>(
'SERVER_STORAGE_CONFIG'
);
+3
View File
@@ -127,6 +127,9 @@
"@iptvnator/services/playlist-backup": [
"libs/services/src/lib/playlist-backup.service.ts"
],
"@iptvnator/services/server-storage": [
"libs/services/src/lib/server-storage/index.ts"
],
"@iptvnator/shared/interfaces": [
"libs/shared/interfaces/src/index.ts"
],