From bb4e386c6ca4330d121f38f9053ef65baa81940e Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 24 May 2026 14:59:08 +0300 Subject: [PATCH] fix(pwa): avoid CSP-blocked stylesheet deferral --- apps/web-e2e/playwright.config.ts | 19 +++++-- apps/web-e2e/src/pwa-styles.e2e.ts | 69 ++++++++++++++++++++++++++ apps/web/project.json | 19 ++++++- docs/architecture/electron-security.md | 6 +++ docs/architecture/pwa-self-hosted.md | 4 ++ ngsw-config.json | 1 + 6 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 apps/web-e2e/src/pwa-styles.e2e.ts diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts index bcf94dc43..cb60a5824 100644 --- a/apps/web-e2e/playwright.config.ts +++ b/apps/web-e2e/playwright.config.ts @@ -2,8 +2,19 @@ import { defineConfig, devices } from '@playwright/test'; import { nxE2EPreset } from '@nx/playwright/preset'; import { workspaceRoot } from '@nx/devkit'; +const isStaticPwaE2E = process.env['IPTVNATOR_E2E_STATIC_PWA'] === '1'; +const staticPwaPort = process.env['IPTVNATOR_E2E_STATIC_PORT'] ?? '4300'; // For CI, you may want to set BASE_URL to the deployed application. -const baseURL = process.env['BASE_URL'] || 'http://localhost:4200'; +const baseURL = + process.env['BASE_URL'] || + (isStaticPwaE2E + ? `http://localhost:${staticPwaPort}` + : 'http://localhost:4200'); +const webServerCommand = + isStaticPwaE2E + ? `pnpm nx run web:serve-static --port=${staticPwaPort}` + : 'pnpm nx run web:serve'; +const reuseExistingWebServer = isStaticPwaE2E ? false : !process.env['CI']; /** * Read environment variables from file. @@ -29,9 +40,9 @@ export default defineConfig({ */ webServer: [ { - command: 'pnpm nx run web:serve', - url: 'http://localhost:4200', - reuseExistingServer: !process.env['CI'], + command: webServerCommand, + url: baseURL, + reuseExistingServer: reuseExistingWebServer, cwd: workspaceRoot, }, { diff --git a/apps/web-e2e/src/pwa-styles.e2e.ts b/apps/web-e2e/src/pwa-styles.e2e.ts new file mode 100644 index 000000000..7a92f304d --- /dev/null +++ b/apps/web-e2e/src/pwa-styles.e2e.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test'; + +test('@pwa-static PWA build applies the full stylesheet under CSP', async ({ + page, +}) => { + const cspConsoleErrors: string[] = []; + page.on('console', (message) => { + if ( + message.type() === 'error' && + /content security policy|inline event handler|onload/i.test( + message.text() + ) + ) { + cspConsoleErrors.push(message.text()); + } + }); + + await page.goto('/'); + + const stylesheetLinks = page.locator('link[rel="stylesheet"]'); + await expect(stylesheetLinks.first()).toBeAttached(); + + const stylesheetMedia = await stylesheetLinks.evaluateAll((links) => + links.map((link) => ({ + href: link.getAttribute('href'), + media: link.getAttribute('media'), + })) + ); + + const appStylesheet = stylesheetMedia.find((link) => + /^styles-.*\.css$/.test(link.href ?? '') + ); + expect(appStylesheet).toBeDefined(); + expect(appStylesheet?.media).not.toBe('print'); + + const materialIcon = page.locator('mat-icon.material-icons').first(); + await expect(materialIcon).toBeVisible(); + + await expect + .poll(() => + materialIcon.evaluate((icon) => + getComputedStyle(icon).fontFamily.toLowerCase() + ) + ) + .toContain('material icons'); + + await expect + .poll(() => + page.evaluate(() => + document.fonts.check('24px "Material Icons"') + ) + ) + .toBe(true); + + const ngswResponse = await page.request.get('/ngsw.json'); + await expect(ngswResponse).toBeOK(); + + const ngswManifest = (await ngswResponse.json()) as { + assetGroups?: Array<{ urls?: string[] }>; + }; + const cachedUrls = ngswManifest.assetGroups?.flatMap( + (assetGroup) => assetGroup.urls ?? [] + ); + + expect(cachedUrls).toEqual( + expect.arrayContaining([expect.stringMatching(/^(?:\.\/|\/)media\//)]) + ); + expect(cspConsoleErrors).toEqual([]); +}); diff --git a/apps/web/project.json b/apps/web/project.json index 548533c67..f7b179709 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -66,6 +66,15 @@ "maximumError": "15kb" } ], + "optimization": { + "scripts": true, + "styles": { + "minify": true, + "inlineCritical": false, + "removeSpecialComments": true + }, + "fonts": true + }, "outputHashing": "all", "fileReplacements": [ { @@ -89,7 +98,15 @@ } ], "outputHashing": "all", - "optimization": true, + "optimization": { + "scripts": true, + "styles": { + "minify": true, + "inlineCritical": false, + "removeSpecialComments": true + }, + "fonts": true + }, "extractLicenses": true, "sourceMap": false, "fileReplacements": [ diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 1f8bfd9ab..696240a54 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -47,6 +47,12 @@ sources through `media-src` and `connect-src` for `http:`, `https:`, `blob:`, and `data:`. The policy keeps `script-src` self-hosted and currently keeps `unsafe-inline` for existing inline styles. +Angular production builds must not rely on inline event handlers for stylesheet +activation. Keep `web:build:production` and `web:build:pwa` configured without +critical CSS stylesheet deferral (`optimization.styles.inlineCritical: false`) +unless the CSP is intentionally changed and runtime-validated in both Electron +and the self-hosted PWA. + Before tightening either value, validate both Electron development startup and the PWA/electron build configurations. Playback-heavy changes should also check that HLS, MPEG-TS, thumbnails, and local file playback are still allowed by the diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index fccdc48e4..2288fa1d3 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -40,6 +40,10 @@ The build must emit these files in `dist/apps/web`: - `safety-worker.js` - `worker-basic.min.js` +Angular also emits hashed font and media assets under `dist/apps/web/media/`. +Keep `/media/**` in `ngsw-config.json` so the PWA service worker can cache +bundled fonts, including Material Icons. + `web:serve-static` serves `dist/apps/web` and builds with `web:build:pwa`, so it exercises the same output layout as Docker. If Nx daemon state returns stale service worker outputs while changing build options, run: diff --git a/ngsw-config.json b/ngsw-config.json index e997fdbf5..7d9de24ef 100644 --- a/ngsw-config.json +++ b/ngsw-config.json @@ -23,6 +23,7 @@ "files": [ "/assets/**", "!/assets/app-config.js", + "/media/**", "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" ] }