diff --git a/apps/web/project.json b/apps/web/project.json index 6e4871dcd..548533c67 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -22,6 +22,7 @@ "polyfills": ["zone.js"], "tsConfig": "apps/web/tsconfig.app.json", "inlineStyleLanguage": "scss", + "serviceWorker": "ngsw-config.json", "assets": [ "apps/web/src/favicon.ico", "apps/web/src/assets", @@ -99,12 +100,14 @@ ] }, "development": { + "serviceWorker": false, "optimization": false, "extractLicenses": false, "sourceMap": true }, "electron-e2e": { "baseHref": "./", + "serviceWorker": false, "optimization": false, "extractLicenses": false, "sourceMap": true @@ -166,9 +169,9 @@ "continuous": true, "executor": "@nx/web:file-server", "options": { - "buildTarget": "web:build", + "buildTarget": "web:build:pwa", "port": 4200, - "staticFilePath": "dist/apps/web/browser", + "staticFilePath": "dist/apps/web", "spa": true } } diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index eafb9250f..70bb84b3f 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -40,6 +40,7 @@ import { } from './services/portal-navigation-actions.service'; import { providePortalPlaybackPositions } from './services/portal-playback-positions.service'; import { PwaService } from './services/pwa.service'; +import { shouldEnableServiceWorker } from './services/runtime-config'; import { provideWorkspaceShellActions } from './services/workspace-shell-actions.service'; // AoT requires an exported function for factories @@ -113,7 +114,7 @@ export const appConfig: ApplicationConfig = { provideRouterStore(), ...(AppConfig.production ? [] : [provideStoreDevtools({ maxAge: 25 })]), provideServiceWorker('ngsw-worker.js', { - enabled: AppConfig.production && !!window.electron, + enabled: shouldEnableServiceWorker(), registrationStrategy: 'registerWhenStable:30000', }), importProvidersFrom( diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index e231cdba6..18f74b762 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -25,6 +25,7 @@ import { logPortalDebugEvent, logPortalDebugRequest, } from '@iptvnator/portal/shared/util'; +import { getRuntimeBackendUrl } from './runtime-config'; interface PwaXtreamResponse { readonly payload?: unknown; @@ -69,7 +70,7 @@ export class PwaService extends DataService { ]); /** Proxy URL to avoid CORS issues */ - corsProxyUrl = AppConfig.BACKEND_URL; + corsProxyUrl = getRuntimeBackendUrl(); constructor() { super(); diff --git a/apps/web/src/app/services/runtime-config.spec.ts b/apps/web/src/app/services/runtime-config.spec.ts new file mode 100644 index 000000000..3efffcf35 --- /dev/null +++ b/apps/web/src/app/services/runtime-config.spec.ts @@ -0,0 +1,39 @@ +import { resolveBackendUrl, shouldEnableServiceWorker } from './runtime-config'; + +describe('runtime config helpers', () => { + it('uses runtime BACKEND_URL when provided', () => { + expect( + resolveBackendUrl( + { BACKEND_URL: ' http://self-hosted.local/api ' }, + 'https://fallback.example' + ) + ).toBe('http://self-hosted.local/api'); + }); + + it('falls back to the build-time backend URL when runtime config is missing or blank', () => { + expect(resolveBackendUrl(undefined, 'https://fallback.example')).toBe( + 'https://fallback.example' + ); + expect(resolveBackendUrl({}, 'https://fallback.example')).toBe( + 'https://fallback.example' + ); + expect( + resolveBackendUrl( + { BACKEND_URL: ' ' }, + 'https://fallback.example' + ) + ).toBe('https://fallback.example'); + }); + + it('enables service worker only for production builds with browser support', () => { + expect( + shouldEnableServiceWorker(true, { + serviceWorker: {}, + } as Navigator) + ).toBe(true); + expect( + shouldEnableServiceWorker(false, { serviceWorker: {} } as Navigator) + ).toBe(false); + expect(shouldEnableServiceWorker(true, {} as Navigator)).toBe(false); + }); +}); diff --git a/apps/web/src/app/services/runtime-config.ts b/apps/web/src/app/services/runtime-config.ts new file mode 100644 index 000000000..2a542a9b0 --- /dev/null +++ b/apps/web/src/app/services/runtime-config.ts @@ -0,0 +1,27 @@ +import { AppConfig } from '../../environments/environment'; + +export interface IptvnatorRuntimeConfig { + readonly BACKEND_URL?: string; +} + +export function resolveBackendUrl( + runtimeConfig: IptvnatorRuntimeConfig | undefined, + fallbackUrl: string +): string { + const runtimeUrl = runtimeConfig?.BACKEND_URL?.trim(); + return runtimeUrl || fallbackUrl; +} + +export function getRuntimeBackendUrl(): string { + return resolveBackendUrl( + globalThis.window?.__IPTVNATOR_CONFIG__, + AppConfig.BACKEND_URL + ); +} + +export function shouldEnableServiceWorker( + production = AppConfig.production, + navigatorRef: Navigator | undefined = globalThis.navigator +): boolean { + return production && !!navigatorRef && 'serviceWorker' in navigatorRef; +} diff --git a/apps/web/src/assets/app-config.js b/apps/web/src/assets/app-config.js new file mode 100644 index 000000000..bc466029f --- /dev/null +++ b/apps/web/src/assets/app-config.js @@ -0,0 +1 @@ +window.__IPTVNATOR_CONFIG__ = window.__IPTVNATOR_CONFIG__ || {}; diff --git a/apps/web/src/index.html b/apps/web/src/index.html index ef7b41fe9..21cd9eeea 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -20,6 +20,7 @@ + -
+
IPTVnator
diff --git a/global.d.ts b/global.d.ts index 7e40923f2..e56f47274 100644 --- a/global.d.ts +++ b/global.d.ts @@ -37,6 +37,9 @@ declare global { } interface Window { + __IPTVNATOR_CONFIG__?: { + BACKEND_URL?: string; + }; electron: { onPlaylistRefreshEvent?: ( callback: (data: PlaylistRefreshEvent) => void diff --git a/ngsw-config.json b/ngsw-config.json index f8bf2102e..e997fdbf5 100644 --- a/ngsw-config.json +++ b/ngsw-config.json @@ -1,30 +1,31 @@ { - "$schema": "./node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", - "assetGroups": [ - { - "name": "app", - "installMode": "prefetch", - "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/manifest.webmanifest", - "/*.css", - "/*.js" - ] - } - }, - { - "name": "assets", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/**", - "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" - ] - } - } - ] + "$schema": "./node_modules/@angular/service-worker/config/schema.json", + "index": "/index.html", + "assetGroups": [ + { + "name": "app", + "installMode": "prefetch", + "resources": { + "files": [ + "/favicon.ico", + "/index.html", + "/manifest.webmanifest", + "/*.css", + "/*.js" + ] + } + }, + { + "name": "assets", + "installMode": "lazy", + "updateMode": "prefetch", + "resources": { + "files": [ + "/assets/**", + "!/assets/app-config.js", + "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" + ] + } + } + ] }