feat(web-backend): add self-hosted proxy app

This commit is contained in:
4gray committed 2026-05-15 11:20:35 +02:00
1 parent d24c77a143
commit f0af34edef
11 files changed
+723

No files matched your search

@@ -0,0 +1,249 @@
import { AddressInfo } from 'node:net';
import { Server } from 'node:http';
import {
createWebBackendApp,
WebBackendHttpClient,
WebBackendHttpGetOptions,
} from './web-backend-app';
interface HttpRequest {
readonly headers?: Record<string, string>;
readonly params?: Record<string, string>;
readonly responseData: unknown;
readonly responseStatus?: number;
readonly url: string;
}
class StubHttpClient implements WebBackendHttpClient {
readonly requests: Omit<HttpRequest, 'responseData' | 'responseStatus'>[] =
[];
private readonly queuedResponses: Array<{
readonly data: unknown;
readonly status?: number;
readonly statusText?: string;
}> = [];
queueResponse(data: unknown): void {
this.queuedResponses.push({ data });
}
queueFailure(status: number, statusText = 'Provider failure'): void {
this.queuedResponses.push({ data: null, status, statusText });
}
async get<T>(
url: string,
options: WebBackendHttpGetOptions = {}
): Promise<{ data: T }> {
this.requests.push({
headers: options.headers,
params: options.params,
url,
});
const response = this.queuedResponses.shift();
if (!response) {
throw new Error(`No queued response for ${url}`);
}
if (response.status) {
const error = new Error(response.statusText) as Error & {
response: { status: number; statusText: string };
};
error.response = {
status: response.status,
statusText: response.statusText ?? 'Provider failure',
};
throw error;
}
return { data: response.data as T };
}
}
async function withServer<T>(
app: ReturnType<typeof createWebBackendApp>,
callback: (baseUrl: string) => Promise<T>
): Promise<T> {
const server = await new Promise<Server>((resolve) => {
const started = app.listen(0, '127.0.0.1', () => resolve(started));
});
try {
const address = server.address() as AddressInfo;
return await callback(`http://127.0.0.1:${address.port}`);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
}
describe('web backend app', () => {
it('exposes a health endpoint', async () => {
await withServer(createWebBackendApp(), async (baseUrl) => {
const response = await fetch(`${baseUrl}/health`);
await expect(response.json()).resolves.toEqual({
status: 'ok',
service: 'iptvnator-web-backend',
});
});
});
it('serves runtime config as executable JavaScript', async () => {
await withServer(
createWebBackendApp({
runtimeBackendUrl: '/api',
}),
async (baseUrl) => {
const response = await fetch(`${baseUrl}/config.js`);
const body = await response.text();
expect(response.headers.get('content-type')).toContain(
'application/javascript'
);
expect(body).toContain('window.__IPTVNATOR_CONFIG__');
expect(body).toContain('"BACKEND_URL":"/api"');
}
);
});
it('parses remote M3U playlists into the PWA playlist shape', async () => {
const httpClient = new StubHttpClient();
httpClient.queueResponse(`#EXTM3U
#EXTINF:-1 tvg-id="news" group-title="News",News Channel
https://stream.example/news.m3u8`);
await withServer(
createWebBackendApp({
clientOrigins: ['http://localhost:4200'],
guid: () => 'fixed-id',
httpClient,
now: () => new Date('2026-05-15T08:00:00.000Z'),
}),
async (baseUrl) => {
const response = await fetch(
`${baseUrl}/parse?url=${encodeURIComponent('https://provider.example/list.m3u')}`,
{ headers: { Origin: 'http://localhost:4200' } }
);
const body = (await response.json()) as {
playlist: { items: Array<Record<string, unknown>> };
};
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBe(
'http://localhost:4200'
);
expect(body).toMatchObject({
_id: 'fixed-id',
autoRefresh: false,
count: 1,
favorites: [],
filename: 'list.m3u',
id: 'fixed-id',
importDate: '2026-05-15T08:00:00.000Z',
lastUsage: '2026-05-15T08:00:00.000Z',
title: 'list.m3u',
url: 'https://provider.example/list.m3u',
});
expect(body.playlist.items).toHaveLength(1);
expect(body.playlist.items[0]).toMatchObject({
id: 'fixed-id',
name: 'News Channel',
url: 'https://stream.example/news.m3u8',
});
expect(httpClient.requests).toEqual([
{
headers: undefined,
params: undefined,
url: 'https://provider.example/list.m3u',
},
]);
}
);
});
it('proxies Xtream requests through the provider player API endpoint', async () => {
const httpClient = new StubHttpClient();
httpClient.queueResponse({ user_info: { username: 'demo' } });
await withServer(
createWebBackendApp({ httpClient }),
async (baseUrl) => {
const response = await fetch(
`${baseUrl}/xtream?url=${encodeURIComponent('http://xtream.example')}&username=demo&password=secret&action=get_account_info`
);
await expect(response.json()).resolves.toEqual({
action: 'get_account_info',
payload: { user_info: { username: 'demo' } },
});
expect(httpClient.requests).toEqual([
{
headers: undefined,
params: {
action: 'get_account_info',
password: 'secret',
username: 'demo',
},
url: 'http://xtream.example/player_api.php',
},
]);
}
);
});
it('proxies Stalker requests with MAC cookie and bearer token', async () => {
const httpClient = new StubHttpClient();
httpClient.queueResponse({ js: [{ id: '2001', title: 'Action' }] });
await withServer(
createWebBackendApp({ httpClient }),
async (baseUrl) => {
const response = await fetch(
`${baseUrl}/stalker?url=${encodeURIComponent('http://stalker.example/portal.php')}&macAddress=00:1A:79:00:00:01&token=abc123&action=get_categories&type=vod`
);
await expect(response.json()).resolves.toEqual({
action: 'get_categories',
payload: { js: [{ id: '2001', title: 'Action' }] },
});
expect(httpClient.requests).toEqual([
{
headers: {
Authorization: 'Bearer abc123',
Cookie: 'mac=00:1A:79:00:00:01',
},
params: {
action: 'get_categories',
macAddress: '00:1A:79:00:00:01',
token: 'abc123',
type: 'vod',
},
url: 'http://stalker.example/portal.php',
},
]);
}
);
});
it('normalizes provider errors for portal proxy calls', async () => {
const httpClient = new StubHttpClient();
httpClient.queueFailure(403, 'Forbidden');
await withServer(
createWebBackendApp({ httpClient }),
async (baseUrl) => {
const response = await fetch(
`${baseUrl}/xtream?url=${encodeURIComponent('http://xtream.example')}&action=get_account_info`
);
await expect(response.json()).resolves.toEqual({
message: 'Forbidden',
status: 403,
});
}
);
});
});
+340
View File
@@ -0,0 +1,340 @@
import cors from 'cors';
import express, { Express, Request } from 'express';
import https from 'node:https';
import zlib from 'node:zlib';
import axios from 'axios';
import epgParser from 'epg-parser';
import parser from 'iptv-playlist-parser';
export interface WebBackendHttpGetOptions {
readonly headers?: Record<string, string>;
readonly httpsAgent?: unknown;
readonly params?: Record<string, string>;
readonly responseType?: 'arraybuffer';
}
export interface WebBackendHttpClient {
get<T>(
url: string,
options?: WebBackendHttpGetOptions
): Promise<{ data: T }>;
}
interface ProviderError extends Error {
readonly response?: {
readonly status?: number;
readonly statusText?: string;
};
}
interface PlaylistParseError {
readonly message: string;
readonly status: number;
}
export interface WebBackendAppOptions {
readonly clientOrigins?: string[];
readonly guid?: () => string;
readonly httpClient?: WebBackendHttpClient;
readonly now?: () => Date;
readonly runtimeBackendUrl?: string;
}
const defaultHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
export function createWebBackendApp(
options: WebBackendAppOptions = {}
): Express {
const app = express();
const httpClient = (options.httpClient ?? axios) as WebBackendHttpClient;
const guid = options.guid ?? createGuid;
const now = options.now ?? (() => new Date());
const clientOrigins = options.clientOrigins ?? getClientOrigins();
const runtimeBackendUrl =
options.runtimeBackendUrl ?? process.env['BACKEND_URL'] ?? '/api';
const corsMiddleware = cors({
origin(origin, callback) {
if (
!origin ||
clientOrigins.includes('*') ||
clientOrigins.includes(origin)
) {
callback(null, true);
return;
}
callback(null, false);
},
optionsSuccessStatus: 200,
});
app.get('/', (_req, res) => res.send('IPTVnator web backend'));
app.get('/health', (_req, res) =>
res.json({ status: 'ok', service: 'iptvnator-web-backend' })
);
app.get('/config.js', corsMiddleware, (_req, res) => {
const config = JSON.stringify({ BACKEND_URL: runtimeBackendUrl });
res.type('application/javascript').send(
`window.__IPTVNATOR_CONFIG__ = Object.assign({}, window.__IPTVNATOR_CONFIG__, ${config});\n`
);
});
app.get('/parse', corsMiddleware, async (req, res) => {
const url = getQueryString(req, 'url');
if (!url) {
res.status(400).send('Missing url');
return;
}
const result = await handlePlaylistParse({
guid,
httpClient,
now,
url,
});
if (isPlaylistParseError(result)) {
res.status(result.status).send(result.message);
return;
}
res.json(result);
});
app.get('/parse-xml', corsMiddleware, async (req, res) => {
const url = getQueryString(req, 'url');
if (!url) {
res.status(400).send('Missing url');
return;
}
const result = await fetchEpgDataFromUrl(httpClient, url);
if (!result) {
res.status(500).send('Error, something went wrong');
return;
}
res.json(result);
});
app.get('/xtream', corsMiddleware, async (req, res) => {
const url = getQueryString(req, 'url');
if (!url) {
res.status(400).json({ message: 'Missing url', status: 400 });
return;
}
try {
const response = await httpClient.get(
`${trimTrailingSlash(url)}/player_api.php`,
{
params: getProxyParams(req, ['url']),
}
);
res.json({
action: getQueryString(req, 'action'),
payload: response.data,
});
} catch (error) {
res.json(normalizeProviderError(error));
}
});
app.get('/stalker', corsMiddleware, async (req, res) => {
const url = getQueryString(req, 'url');
const macAddress = getQueryString(req, 'macAddress');
const token = getQueryString(req, 'token');
if (!url) {
res.status(400).json({ message: 'Missing url', status: 400 });
return;
}
try {
const response = await httpClient.get(url, {
params: getProxyParams(req, ['url']),
headers: {
...(macAddress ? { Cookie: `mac=${macAddress}` } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
res.json({
action: getQueryString(req, 'action'),
payload: response.data,
});
} catch (error) {
res.json(normalizeProviderError(error));
}
});
return app;
}
function getClientOrigins(): string[] {
const configured = process.env['CLIENT_URL']
?.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
if (configured?.length) {
return configured;
}
return process.env['NODE_ENV'] === 'development' ||
process.env['NODE_ENV'] === 'dev'
? ['http://localhost:4200']
: ['https://iptvnator.vercel.app'];
}
function getQueryString(req: Request, key: string): string | undefined {
const value = req.query[key];
if (Array.isArray(value)) {
return normalizeQueryValue(value[0]);
}
return normalizeQueryValue(value);
}
function normalizeQueryValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
function getProxyParams(
req: Request,
excludedKeys: string[]
): Record<string, string> {
const excluded = new Set(excludedKeys);
const params: Record<string, string> = {};
for (const [key, value] of Object.entries(req.query)) {
if (excluded.has(key)) {
continue;
}
const normalized = Array.isArray(value)
? normalizeQueryValue(value[0])
: normalizeQueryValue(value);
if (normalized) {
params[key] = normalized;
}
}
return params;
}
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '');
}
async function handlePlaylistParse(options: {
readonly guid: () => string;
readonly httpClient: WebBackendHttpClient;
readonly now: () => Date;
readonly url: string;
}): Promise<Record<string, unknown> | PlaylistParseError> {
try {
const response = await options.httpClient.get<string>(options.url, {
httpsAgent: defaultHttpsAgent,
});
const parsedPlaylist = parsePlaylist(response.data);
const title = getLastUrlSegment(options.url);
return createPlaylistObject({
guid: options.guid,
now: options.now,
playlist: parsedPlaylist,
title,
url: options.url,
});
} catch (error) {
const providerError = error as ProviderError;
return {
status: providerError.response?.status ?? 500,
message:
providerError.response?.statusText ??
'Error, something went wrong',
};
}
}
async function fetchEpgDataFromUrl(
httpClient: WebBackendHttpClient,
url: string
): Promise<unknown> {
const response = await httpClient.get<ArrayBuffer | string>(url.trim(), {
...(url.endsWith('.gz') ? { responseType: 'arraybuffer' } : {}),
});
const xml = url.endsWith('.gz')
? zlib.gunzipSync(Buffer.from(response.data as ArrayBuffer)).toString()
: response.data.toString();
return epgParser.parse(xml);
}
function isPlaylistParseError(
result: Record<string, unknown> | PlaylistParseError
): result is PlaylistParseError {
return (
typeof (result as PlaylistParseError).status === 'number' &&
typeof (result as PlaylistParseError).message === 'string'
);
}
function parsePlaylist(
playlist: string
): { items: Array<Record<string, unknown>> } {
return parser.parse(playlist) as unknown as {
items: Array<Record<string, unknown>>;
};
}
function createPlaylistObject(options: {
readonly guid: () => string;
readonly now: () => Date;
readonly playlist: { items: Array<Record<string, unknown>> };
readonly title: string;
readonly url: string;
}): Record<string, unknown> {
const timestamp = options.now().toISOString();
return {
id: options.guid(),
_id: options.guid(),
filename: options.title,
title: options.title,
count: options.playlist.items.length,
playlist: {
...options.playlist,
items: options.playlist.items.map((item) => ({
id: options.guid(),
...item,
})),
},
importDate: timestamp,
lastUsage: timestamp,
favorites: [],
autoRefresh: false,
url: options.url,
};
}
function getLastUrlSegment(value: string): string {
const segment = value.slice(value.lastIndexOf('/') + 1).trim();
return segment.length > 0 ? segment : 'Playlist without title';
}
function normalizeProviderError(error: unknown): {
readonly message: string;
readonly status: number;
} {
const providerError = error as ProviderError;
return {
message: providerError.response?.statusText ?? 'Error: not found',
status: providerError.response?.status ?? 404,
};
}
function createGuid(): string {
return Math.random().toString(36).slice(2);
}
+8
View File
@@ -0,0 +1,8 @@
import { createWebBackendApp } from './app/web-backend-app';
const port = Number(process.env['PORT'] ?? 3000);
const app = createWebBackendApp();
app.listen(port, () => {
console.log(`IPTVnator web backend listening on http://localhost:${port}`);
});
+7
View File
@@ -0,0 +1,7 @@
declare module 'epg-parser' {
const parser: {
parse(xml: string): unknown;
};
export default parser;
}