From b74584fdb21c58b83d7ce01d19ca33fb33503dad Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 11 Oct 2026 22:49:58 +0200 Subject: [PATCH] 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 --- libs/services/src/lib/server-storage/index.ts | 2 + .../server-storage/server-api-client.spec.ts | 98 +++++++++++ .../lib/server-storage/server-api-client.ts | 159 ++++++++++++++++++ .../server-storage/server-storage.config.ts | 12 ++ tsconfig.base.json | 3 + 5 files changed, 274 insertions(+) create mode 100644 libs/services/src/lib/server-storage/index.ts create mode 100644 libs/services/src/lib/server-storage/server-api-client.spec.ts create mode 100644 libs/services/src/lib/server-storage/server-api-client.ts create mode 100644 libs/services/src/lib/server-storage/server-storage.config.ts diff --git a/libs/services/src/lib/server-storage/index.ts b/libs/services/src/lib/server-storage/index.ts new file mode 100644 index 000000000..cd2d294ba --- /dev/null +++ b/libs/services/src/lib/server-storage/index.ts @@ -0,0 +1,2 @@ +export * from './server-storage.config'; +export * from './server-api-client'; diff --git a/libs/services/src/lib/server-storage/server-api-client.spec.ts b/libs/services/src/lib/server-storage/server-api-client.spec.ts new file mode 100644 index 000000000..a61c6ad8e --- /dev/null +++ b/libs/services/src/lib/server-storage/server-api-client.spec.ts @@ -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(); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-api-client.ts b/libs/services/src/lib/server-storage/server-api-client.ts new file mode 100644 index 000000000..92d36a91b --- /dev/null +++ b/libs/services/src/lib/server-storage/server-api-client.ts @@ -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 { + 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 | 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(path: string): Promise { + return (await this.request(path)).json() as Promise; + } + + sendJson( + method: 'PUT' | 'POST', + path: string, + body: unknown + ): Promise { + 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; + return { + message: body.message ?? response.statusText, + code: body.code, + }; + } catch { + return { message: response.statusText }; + } +} diff --git a/libs/services/src/lib/server-storage/server-storage.config.ts b/libs/services/src/lib/server-storage/server-storage.config.ts new file mode 100644 index 000000000..624d20bbe --- /dev/null +++ b/libs/services/src/lib/server-storage/server-storage.config.ts @@ -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( + 'SERVER_STORAGE_CONFIG' +); diff --git a/tsconfig.base.json b/tsconfig.base.json index 01a5e5b29..01d9774cb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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" ],