diff --git a/.github/workflows/react-app.yml b/.github/workflows/react-app.yml new file mode 100644 index 000000000..cee6adfec --- /dev/null +++ b/.github/workflows/react-app.yml @@ -0,0 +1,100 @@ +name: React app + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + working-directory: react-app + +jobs: + check: + name: Lint, typecheck, test, build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: react-app/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: npm run build + - run: npm run format:check + + e2e: + name: Playwright (offline / PWA) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: react-app/package-lock.json + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run e2e + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: | + react-app/playwright-report + react-app/test-results + retention-days: 7 + + deploy: + name: Deploy to Firebase Hosting + needs: [check, e2e] + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + defaults: + run: + working-directory: . + steps: + - name: Check for Firebase credentials + id: creds + env: + FIREBASE_SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} + FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} + run: | + [ -n "$FIREBASE_SERVICE_ACCOUNT" ] && echo "service_account=true" >> "$GITHUB_OUTPUT" + [ -n "$FIREBASE_TOKEN" ] && echo "token=true" >> "$GITHUB_OUTPUT" + if [ -z "$FIREBASE_SERVICE_ACCOUNT" ] && [ -z "$FIREBASE_TOKEN" ]; then + echo "::notice::No FIREBASE_SERVICE_ACCOUNT or FIREBASE_TOKEN secret; skipping deploy." + fi + - uses: actions/checkout@v4 + if: steps.creds.outputs.service_account == 'true' || steps.creds.outputs.token == 'true' + - uses: actions/setup-node@v4 + if: steps.creds.outputs.service_account == 'true' || steps.creds.outputs.token == 'true' + with: + node-version: 20 + cache: npm + cache-dependency-path: react-app/package-lock.json + # firebase.json's hosting predeploy runs `npm ci` + `npm run build` in react-app/. + - name: Deploy (service account) + if: steps.creds.outputs.service_account == 'true' + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} + projectId: angular2-hn + channelId: live + - name: Deploy (token) + if: steps.creds.outputs.service_account != 'true' && steps.creds.outputs.token == 'true' + env: + FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} + run: npx --yes firebase-tools@15 deploy --only hosting --project default --non-interactive diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ff155b5fa..000000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -language: node_js -node_js: - - "6.9" - -branches: - only: - - master - -before_script: - - npm install -g firebase-tools - - npm install -g @angular/cli - -script: - - npm run build - -after_success: - - firebase use default - - firebase deploy --token $FIREBASE_TOKEN - -notifications: - email: - on_failure: change - on_success: change diff --git a/firebase.json b/firebase.json index 67d566789..5c3e99038 100644 --- a/firebase.json +++ b/firebase.json @@ -3,12 +3,28 @@ "rules": "database.rules.json" }, "hosting": { - "public": "dist", + "public": "react-app/dist", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "predeploy": ["npm --prefix \"$PROJECT_DIR/react-app\" ci", "npm --prefix \"$PROJECT_DIR/react-app\" run build"], "rewrites": [ { "source": "**", "destination": "/index.html" } + ], + "headers": [ + { + "regex": "^/assets/[^/]+-[A-Za-z0-9_-]{8}\\.[a-z0-9]+$", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] + }, + { + "source": "/@(sw.js|ngsw-worker.js|index.html|manifest.webmanifest)", + "headers": [{ "key": "Cache-Control", "value": "no-cache" }] + }, + { + "source": "{/,**/!(*.*)}", + "headers": [{ "key": "Cache-Control", "value": "no-cache" }] + } ] } } diff --git a/react-app/README.md b/react-app/README.md index c41ac7579..0c129187d 100644 --- a/react-app/README.md +++ b/react-app/README.md @@ -44,3 +44,40 @@ Requires Node >= 20.19. - **Links**: use `` from `react-router` for internal routes (`/item/:id`, `/user/:id`, `/:feed/:page`). For `routerLinkActive="active"`, use `` (it adds `active`). - **Tests**: Vitest + Testing Library, `*.test.tsx` next to the component. Use the helpers in `src/test`: `renderApp({ route })` (full route tree), `renderWithProviders(ui, { path, route, settings, handle })`, `mockFetch({ 'url-substring': body | { status, body } })`, and the fixtures in `test/fixtures.ts`. - **Formatting**: Prettier settings are copied from the root `package.json` (4 spaces, single quotes, 120 cols, es5 trailing commas). + +## Build & deploy + +### PWA + +`vite-plugin-pwa` (`generateSW`, `registerType: 'autoUpdate'`) is configured in `vite.config.ts` and only runs on `npm run build`; the dev server has no service worker. + +- **Manifest**: generated as `dist/manifest.webmanifest` from the same fields as the Angular `src/manifest.json`. The plugin adds `` to `index.html`. +- **Precache**: JS/CSS/HTML/ico/png/svg in `dist/` (the app shell, lazy route chunks and `public/assets` icons) plus the manifest. Only `workbox.globPatterns` selects files. Don't add `includeAssets`, and keep `includeManifestIcons: false`: a URL listed twice makes Workbox throw `add-to-cache-list-conflicting-entries`, and the SW installs but caches nothing. Check `dist/sw.js` after changing the config. +- **Deep links**: `navigateFallback: 'index.html'`, so `/item/123` loads offline. +- **API**: `node-hnapi.herokuapp.com` and `api.hnpwa.com` use NetworkFirst (5 s timeout, 7 day / 200 + 100 entry cap). Pages visited online work offline. +- **Registration**: `src/pwa/index.ts` (imported by `main.tsx`) calls `registerServiceWorker` from `src/pwa/registerSW.ts`. The new SW activates immediately (`skipWaiting` + `clientsClaim`), and open tabs check for an update every hour while online. On the first visit the API requests start before the SW controls the page, so once it takes control `registerSW` re-requests them (from `performance` resource entries) through the SW; otherwise the page you landed on would not work offline. +- **Retiring the Angular SW**: returning visitors still have `@angular/service-worker` registered at `/ngsw-worker.js`. `public/ngsw-worker.js` is a safety worker served at that URL (excluded from the precache, `no-cache` on Firebase): on the browser's next update check it replaces the old worker, deletes the `ngsw:*` caches, unregisters itself and reloads open tabs, which then register `sw.js`. + +### E2E + +`npm run e2e` builds the app and runs Playwright against `vite preview` on port 4173. Service workers are blocked by default, so `page.route` mocks keep working. `e2e/offline.spec.ts` opts back in with `test.use({ serviceWorkers: 'allow' })`. `playwright.config.ts` sets `PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` so `context.route` also sees the SW's fetches. `context.setOffline(true)` doesn't stop the SW's own fetches, so the test also aborts every route. If `npx playwright install` is blocked, the config uses the system Chrome under `/opt/.devin/chrome` or `PLAYWRIGHT_CHROME_PATH`. + +### Firebase Hosting + +`firebase.json` (repo root) serves `react-app/dist`: + +- `predeploy` runs `npm ci` + `npm run build` in `react-app/`. +- SPA rewrite `** → /index.html`. +- `Cache-Control: no-cache` for `sw.js`, `index.html`, `manifest.webmanifest` and extensionless SPA routes. +- `public, max-age=31536000, immutable` for Vite's hashed files (`/assets/-<8 char hash>.`). The unhashed icons in `/assets/icons` and `/assets/images` keep Firebase's default caching. +- The `database` rules entry is unchanged. + +Manual deploy from the repo root: `npx firebase-tools deploy --only hosting` (project aliases are in `.firebaserc`). + +### CI + +`.github/workflows/react-app.yml` replaces `.travis.yml`: + +- **check**: on PRs and pushes to `master`, runs `npm ci`, lint, typecheck, test, build and format:check on Node 20 with the npm cache. +- **e2e**: runs the Playwright offline/PWA tests and uploads the report if they fail. +- **deploy**: on pushes to `master`, after both jobs pass. It uses `FirebaseExtended/action-hosting-deploy` if the `FIREBASE_SERVICE_ACCOUNT` secret is set, or falls back to `firebase-tools` with `FIREBASE_TOKEN`. With neither secret it skips with a notice, so forks don't fail. diff --git a/react-app/e2e/offline.spec.ts b/react-app/e2e/offline.spec.ts new file mode 100644 index 000000000..cb1d1a9df --- /dev/null +++ b/react-app/e2e/offline.spec.ts @@ -0,0 +1,147 @@ +/// +import { expect, test, type BrowserContext, type Page } from '@playwright/test'; + +const story = { + id: 8863, + title: 'My YC app: Dropbox - Throw away your USB drive', + points: 104, + user: 'dhouston', + time: 1175714200, + time_ago: '15 years ago', + comments_count: 71, + type: 'link', + url: 'http://www.getdropbox.com/u/2/screencast.html', + domain: 'getdropbox.com', +}; +const FEED_URL = 'https://node-hnapi.herokuapp.com/news?page=1'; +const ITEM_URL = 'https://node-hnapi.herokuapp.com/item/8863'; + +test.use({ serviceWorkers: 'allow' }); + +// Slow enough that the first feed request is still in flight when the SW takes control. +const API_LATENCY_MS = 1500; + +async function mockApi(context: BrowserContext) { + await context.route('https://node-hnapi.herokuapp.com/**', async (route) => { + await new Promise((resolve) => setTimeout(resolve, API_LATENCY_MS)); + const { pathname } = new URL(route.request().url()); + const body = pathname.startsWith('/item/') ? { ...story, comments: [], content: '' } : [story]; + return route.fulfill({ json: body, headers: { 'access-control-allow-origin': '*' } }); + }); +} + +async function waitForServiceWorkerControl(page: Page) { + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) { + await new Promise((resolve) => + navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }) + ); + } + }); +} + +// setOffline doesn't cover the SW's own fetches and route mocks keep answering while offline, +// so swap the mocks for a route that fails every request (SW fetches included). +async function goOffline(context: BrowserContext) { + await context.unrouteAll(); + await context.route('**/*', (route) => route.abort('internetdisconnected')); + await context.setOffline(true); +} + +function isCached(page: Page, cacheName: string, url: string) { + return page.evaluate(async ([name, u]) => !!(await (await caches.open(name)).match(u)), [cacheName, url] as const); +} + +function fetchJson(page: Page, url: string) { + return page.evaluate(async (u) => (await fetch(u)).json(), url); +} + +test('app shell, deep links and visited API responses work offline', async ({ context, page }) => { + await mockApi(context); + + await page.goto('/news/1'); + await expect(page.locator('#root .main-content')).toBeVisible(); + await expect(page.getByText(story.title).filter({ visible: true }).first()).toBeVisible(); + await waitForServiceWorkerControl(page); + + // First visit: the feed request started before the SW took control, so registerSW re-requests it. + await expect.poll(() => isCached(page, 'hn-api', FEED_URL)).toBe(true); + // Visit the item page client-side (through the SW) so its API response is cached too. + await page.getByRole('link', { name: `${story.comments_count} comments` }).click(); + await expect(page).toHaveURL(/\/item\/8863$/); + await expect(page.getByText(story.title).filter({ visible: true }).first()).toBeVisible(); + await page.goBack(); + + await goOffline(context); + + const reload = await page.reload(); + expect(reload?.fromServiceWorker()).toBe(true); + await expect(page.locator('#root .main-content')).toBeVisible(); + // The feed renders from the SW's runtime cache. + await expect(page.getByText(story.title).filter({ visible: true }).first()).toBeVisible(); + await expect(page).toHaveTitle('Angular 2 HN'); + expect(await fetchJson(page, FEED_URL)).toEqual([story]); + + // Deep link that was never loaded as a document: served by navigateFallback, lazy chunk from precache. + const deepLink = await page.goto('/item/8863'); + expect(deepLink?.fromServiceWorker()).toBe(true); + await expect(page).toHaveURL(/\/item\/8863$/); + await expect(page.locator('#root .main-content')).toBeVisible(); + await expect(page.getByText(story.title).filter({ visible: true }).first()).toBeVisible(); + expect(await fetchJson(page, ITEM_URL)).toMatchObject({ id: story.id, title: story.title }); + + // Pages never visited online have no cached API data. + await expect(fetchJson(page, 'https://node-hnapi.herokuapp.com/item/1')).rejects.toThrow(); +}); + +test('serves a valid web app manifest', async ({ page }) => { + await page.goto('/news/1'); + const href = await page.locator('link[rel="manifest"]').getAttribute('href'); + expect(href).toBe('/manifest.webmanifest'); + await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#b92b27'); + + const manifest = await (await page.request.get(href!)).json(); + expect(manifest).toMatchObject({ + name: 'Angular 2 HN', + short_name: 'Angular 2 HN', + theme_color: '#b92b27', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait', + start_url: './?utm_source=web_app_manifest', + }); + expect(manifest.icons.map((icon: { sizes: string }) => icon.sizes)).toEqual([ + '144x144', + '192x192', + '256x256', + '512x512', + ]); + for (const icon of manifest.icons) { + expect((await page.request.get(icon.src)).ok()).toBe(true); + } +}); + +test('ngsw-worker.js retires a previously installed Angular service worker', async ({ page }) => { + await page.goto('/'); + await waitForServiceWorkerControl(page); + + // Simulate a returning Angular visitor: Angular caches plus a registration for ngsw-worker.js. + // The safety worker reloads the page once it has cleaned up. + const reloaded = page.waitForEvent('load'); + await page.evaluate(async () => { + await (await caches.open('ngsw:/:db:control')).put('/x', new Response('x')); + void navigator.serviceWorker.register('/ngsw-worker.js'); + }); + await reloaded; + + await expect + .poll(() => page.evaluate(async () => (await caches.keys()).filter((key) => key.startsWith('ngsw:')))) + .toEqual([]); + // The reloaded page registers the React SW again. + await expect + .poll(() => + page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.active?.scriptURL ?? '') + ) + .toMatch(/\/sw\.js$/); +}); diff --git a/react-app/playwright.config.ts b/react-app/playwright.config.ts new file mode 100644 index 000000000..f1c5bc7dc --- /dev/null +++ b/react-app/playwright.config.ts @@ -0,0 +1,50 @@ +// Let context.route intercept requests made by the service worker (offline/PWA tests). +process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1'; + +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { defineConfig, devices } from '@playwright/test'; + +const PORT = 4173; +const DEVIN_CHROME_DIR = '/opt/.devin/chrome/chrome'; + +// Falls back to the system Chrome when `npx playwright install` isn't available. +function systemChrome(): string | undefined { + if (process.env.PLAYWRIGHT_CHROME_PATH) { + return process.env.PLAYWRIGHT_CHROME_PATH; + } + if (process.env.CI || !existsSync(DEVIN_CHROME_DIR)) { + return undefined; + } + return readdirSync(DEVIN_CHROME_DIR) + .filter((dir) => dir.startsWith('linux-')) + .map((dir) => join(DEVIN_CHROME_DIR, dir, 'chrome-linux64', 'chrome')) + .find((path) => existsSync(path)); +} + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL: `http://localhost:${PORT}`, + trace: 'retain-on-failure', + // Once a SW controls the page, its fetches skip page.route mocks. Offline tests opt back in. + serviceWorkers: 'block', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], launchOptions: { executablePath: systemChrome() } }, + }, + ], + // Production build: the service worker is only generated by `vite build`. + webServer: { + command: `npm run build && npx vite preview --port ${PORT} --strictPort`, + url: `http://localhost:${PORT}`, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + }, +}); diff --git a/react-app/public/ngsw-worker.js b/react-app/public/ngsw-worker.js new file mode 100644 index 000000000..bf91ddcd5 --- /dev/null +++ b/react-app/public/ngsw-worker.js @@ -0,0 +1,16 @@ +// Safety worker for visitors who still have the Angular app's @angular/service-worker installed. +// The browser fetches this script on its next update check; it replaces the old worker, clears the +// Angular caches, unregisters itself and reloads open tabs so they pick up the React app and its SW. +self.addEventListener('install', () => self.skipWaiting()); + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + const keys = await caches.keys(); + await Promise.all(keys.filter((key) => key.startsWith('ngsw:')).map((key) => caches.delete(key))); + await self.registration.unregister(); + const windows = await self.clients.matchAll({ type: 'window' }); + await Promise.all(windows.map((client) => client.navigate(client.url))); + })() + ); +}); diff --git a/react-app/public/robots.txt b/react-app/public/robots.txt new file mode 100644 index 000000000..c2a49f4fb --- /dev/null +++ b/react-app/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/react-app/src/main.tsx b/react-app/src/main.tsx index 1f7c2c721..c1864e999 100644 --- a/react-app/src/main.tsx +++ b/react-app/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { App } from './App'; import './styles.scss'; +import './pwa'; createRoot(document.getElementById('root')!).render( diff --git a/react-app/src/pwa/index.ts b/react-app/src/pwa/index.ts new file mode 100644 index 000000000..94ca479af --- /dev/null +++ b/react-app/src/pwa/index.ts @@ -0,0 +1,5 @@ +import { registerSW } from 'virtual:pwa-register'; + +import { registerServiceWorker } from './registerSW'; + +registerServiceWorker(registerSW); diff --git a/react-app/src/pwa/registerSW.test.ts b/react-app/src/pwa/registerSW.test.ts new file mode 100644 index 000000000..7bec92043 --- /dev/null +++ b/react-app/src/pwa/registerSW.test.ts @@ -0,0 +1,213 @@ +import type { RegisterSWOptions } from 'virtual:pwa-register'; + +import { IN_FLIGHT_WINDOW_MS, registerServiceWorker, warmRuntimeCache, type RegisterSW } from './registerSW'; + +const entry = (name: string, startTime = 0) => ({ name, startTime }) as PerformanceEntry; + +function stubResourceEntries(entries: PerformanceEntry[]) { + vi.spyOn(performance, 'getEntriesByType').mockReturnValue(entries); +} + +function stubPerformanceObserver() { + const observer = { + callback: undefined as PerformanceObserverCallback | undefined, + observe: vi.fn(), + disconnect: vi.fn(), + }; + vi.stubGlobal( + 'PerformanceObserver', + class { + observe = observer.observe; + disconnect = observer.disconnect; + constructor(callback: PerformanceObserverCallback) { + observer.callback = callback; + } + } + ); + return { + observer, + emit: (entries: PerformanceEntry[]) => + observer.callback?.( + { getEntries: () => entries } as PerformanceObserverEntryList, + {} as PerformanceObserver + ), + }; +} + +function setup({ serviceWorker = true, onLine = true, controller = null as ServiceWorker | null } = {}) { + const addEventListener = vi.fn(); + if (serviceWorker) { + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { controller, addEventListener }, + }); + } + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(onLine); + let options: RegisterSWOptions = {}; + const updateSW = vi.fn(); + const register = vi.fn((opts = {}) => { + options = opts; + return updateSW; + }); + const logger = { info: vi.fn(), error: vi.fn() }; + const result = registerServiceWorker(register, { updateCheckIntervalMs: 1000, logger }); + return { register, updateSW, logger, result, addEventListener, options: () => options }; +} + +function fakeRegistration(overrides: Partial = {}) { + return { + installing: null, + update: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as ServiceWorkerRegistration; +} + +describe('registerServiceWorker', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + Reflect.deleteProperty(navigator, 'serviceWorker'); + }); + + it('re-requests API responses that started before the SW took control on the first visit', () => { + vi.useFakeTimers(); + const fetchMock = vi.fn().mockResolvedValue(new Response('[]')); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(performance, 'now').mockReturnValue(100); + stubResourceEntries([entry('https://node-hnapi.herokuapp.com/news?page=1', 10)]); + const { observer, emit } = stubPerformanceObserver(); + const { addEventListener } = setup(); + + expect(addEventListener).toHaveBeenCalledWith('controllerchange', expect.any(Function), { once: true }); + expect(fetchMock).not.toHaveBeenCalled(); + addEventListener.mock.calls[0][1](); + expect(fetchMock).toHaveBeenCalledWith('https://node-hnapi.herokuapp.com/news?page=1'); + expect(observer.observe).toHaveBeenCalledWith({ type: 'resource' }); + + // A request in flight at control time is re-requested; one started afterwards went through the SW. + // The snapshot entry reported again by the observer is not re-requested a second time. + emit([ + entry('https://node-hnapi.herokuapp.com/news?page=1', 10), + entry('https://node-hnapi.herokuapp.com/item/1', 50), + entry('https://node-hnapi.herokuapp.com/item/2', 150), + ]); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + 'https://node-hnapi.herokuapp.com/news?page=1', + 'https://node-hnapi.herokuapp.com/item/1', + ]); + + vi.advanceTimersByTime(IN_FLIGHT_WINDOW_MS); + expect(observer.disconnect).toHaveBeenCalled(); + }); + + it('does not warm the cache when a SW already controls the page', () => { + const { addEventListener } = setup({ controller: {} as ServiceWorker }); + expect(addEventListener).not.toHaveBeenCalled(); + }); + + it('does nothing when service workers are unsupported', () => { + const { register, result } = setup({ serviceWorker: false }); + expect(register).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('registers immediately and returns the update function', () => { + const { register, updateSW, result, options } = setup(); + expect(register).toHaveBeenCalledTimes(1); + expect(options().immediate).toBe(true); + expect(result).toBe(updateSW); + }); + + it('polls for a new service worker while online', async () => { + const { options } = setup(); + const registration = fakeRegistration(); + options().onRegisteredSW?.('/sw.js', registration); + + expect(registration.update).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1000); + expect(registration.update).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1000); + expect(registration.update).toHaveBeenCalledTimes(2); + }); + + it('skips update checks while offline', async () => { + const { options } = setup({ onLine: false }); + const registration = fakeRegistration(); + options().onRegisteredSW?.('/sw.js', registration); + + await vi.advanceTimersByTimeAsync(1000); + expect(registration.update).not.toHaveBeenCalled(); + }); + + it('skips update checks while a new worker is installing', async () => { + const { options } = setup(); + const registration = fakeRegistration({ installing: {} as ServiceWorker }); + options().onRegisteredSW?.('/sw.js', registration); + + await vi.advanceTimersByTimeAsync(1000); + expect(registration.update).not.toHaveBeenCalled(); + }); + + it('does not poll when registration is missing', () => { + const { options } = setup(); + expect(() => options().onRegisteredSW?.('/sw.js', undefined)).not.toThrow(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('logs failed update checks instead of throwing', async () => { + const { options, logger } = setup(); + const error = new Error('network'); + const registration = fakeRegistration({ update: vi.fn().mockRejectedValue(error) }); + options().onRegisteredSW?.('/sw.js', registration); + + await vi.advanceTimersByTimeAsync(1000); + expect(logger.error).toHaveBeenCalledWith('Service worker update failed', error); + }); + + it('logs offline-ready and registration errors', () => { + const { options, logger } = setup(); + options().onOfflineReady?.(); + expect(logger.info).toHaveBeenCalledWith('App is ready to work offline.'); + + const error = new Error('blocked'); + options().onRegisterError?.(error); + expect(logger.error).toHaveBeenCalledWith('Service worker registration failed', error); + }); +}); + +describe('warmRuntimeCache', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('fetches each runtime-cached API URL once and ignores other resources', async () => { + const entries = [ + 'https://node-hnapi.herokuapp.com/news?page=1', + 'https://node-hnapi.herokuapp.com/news?page=1', + 'https://api.hnpwa.com/v0/user/pg.json', + 'http://localhost:4200/assets/index.js', + 'https://node-hnapi.herokuapp.com.evil.test/news', + ].map((name) => entry(name)); + const fetchMock = vi.fn().mockResolvedValue(new Response('{}')); + + await warmRuntimeCache(entries, fetchMock); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + 'https://node-hnapi.herokuapp.com/news?page=1', + 'https://api.hnpwa.com/v0/user/pg.json', + ]); + }); + + it('settles when a request fails', async () => { + const fetchMock = vi.fn().mockRejectedValue(new TypeError('offline')); + + await expect(warmRuntimeCache([entry('https://node-hnapi.herokuapp.com/item/1')], fetchMock)).resolves.toEqual([ + { status: 'rejected', reason: expect.any(TypeError) }, + ]); + }); +}); diff --git a/react-app/src/pwa/registerSW.ts b/react-app/src/pwa/registerSW.ts new file mode 100644 index 000000000..a10d26b16 --- /dev/null +++ b/react-app/src/pwa/registerSW.ts @@ -0,0 +1,84 @@ +import type { RegisterSWOptions } from 'virtual:pwa-register'; + +import { isRuntimeCachedUrl } from './runtime-cache'; + +export type RegisterSW = (options?: RegisterSWOptions) => (reloadPage?: boolean) => Promise; + +export const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; + +interface Options { + updateCheckIntervalMs?: number; + logger?: Pick; +} + +export const IN_FLIGHT_WINDOW_MS = 60 * 1000; + +export function warmRuntimeCache( + entries: PerformanceEntryList, + fetchFn: typeof fetch = fetch, + warmed = new Set() +) { + const urls = entries.map((entry) => entry.name).filter((url) => isRuntimeCachedUrl(url) && !warmed.has(url)); + const unique = [...new Set(urls)]; + unique.forEach((url) => warmed.add(url)); + return Promise.allSettled(unique.map((url) => fetchFn(url))); +} + +// On the first visit the API requests start before the SW takes control, so they never reach its +// runtime cache. Once it controls the page, request them again through the SW, including requests +// that were still in flight at that moment. +function warmRuntimeCacheOnFirstControl() { + navigator.serviceWorker.addEventListener( + 'controllerchange', + () => { + const controlledAt = performance.now(); + const warmed = new Set(); + const startedBeforeControl = (entries: PerformanceEntryList) => + entries.filter((entry) => entry.startTime < controlledAt); + + void warmRuntimeCache(startedBeforeControl(performance.getEntriesByType('resource')), fetch, warmed); + if (typeof PerformanceObserver === 'undefined') { + return; + } + const observer = new PerformanceObserver( + (list) => void warmRuntimeCache(startedBeforeControl(list.getEntries()), fetch, warmed) + ); + observer.observe({ type: 'resource' }); + setTimeout(() => observer.disconnect(), IN_FLIGHT_WINDOW_MS); + }, + { once: true } + ); +} + +// The SW auto-updates (skipWaiting + clientsClaim); a long-lived tab also polls for a new SW. +export function registerServiceWorker( + register: RegisterSW, + { updateCheckIntervalMs = UPDATE_CHECK_INTERVAL_MS, logger = console }: Options = {} +) { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return undefined; + } + if (!navigator.serviceWorker.controller) { + warmRuntimeCacheOnFirstControl(); + } + return register({ + immediate: true, + onRegisteredSW(_swUrl, registration) { + if (!registration) { + return; + } + setInterval(() => { + if (registration.installing || !navigator.onLine) { + return; + } + registration.update().catch((error: unknown) => logger.error('Service worker update failed', error)); + }, updateCheckIntervalMs); + }, + onOfflineReady() { + logger.info('App is ready to work offline.'); + }, + onRegisterError(error: unknown) { + logger.error('Service worker registration failed', error); + }, + }); +} diff --git a/react-app/src/pwa/runtime-cache.ts b/react-app/src/pwa/runtime-cache.ts new file mode 100644 index 000000000..52dfcea4f --- /dev/null +++ b/react-app/src/pwa/runtime-cache.ts @@ -0,0 +1,9 @@ +// API origins the service worker caches at runtime (NetworkFirst). Shared with vite.config.ts. +export const API_CACHES = [ + { origin: 'https://node-hnapi.herokuapp.com', cacheName: 'hn-api', maxEntries: 200 }, + { origin: 'https://api.hnpwa.com', cacheName: 'hnpwa-api', maxEntries: 100 }, +] as const; + +export function isRuntimeCachedUrl(url: string) { + return API_CACHES.some(({ origin }) => url.startsWith(`${origin}/`)); +} diff --git a/react-app/tsconfig.app.json b/react-app/tsconfig.app.json index 5d62d3263..d8e33e9f1 100644 --- a/react-app/tsconfig.app.json +++ b/react-app/tsconfig.app.json @@ -12,7 +12,7 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", - "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"], + "types": ["vite/client", "vite-plugin-pwa/client", "vitest/globals", "@testing-library/jest-dom"], "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, diff --git a/react-app/vite.config.ts b/react-app/vite.config.ts index c3718cac9..93a5687da 100644 --- a/react-app/vite.config.ts +++ b/react-app/vite.config.ts @@ -1,10 +1,63 @@ /// import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import { VitePWA } from 'vite-plugin-pwa'; + +import { API_CACHES } from './src/pwa/runtime-cache'; + +const API_CACHE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; -// PWA (vite-plugin-pwa) is configured by the PWA/build/deploy workstream. export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + VitePWA({ + strategies: 'generateSW', + registerType: 'autoUpdate', + // src/pwa registers the SW itself (virtual:pwa-register). + injectRegister: false, + // Never combine includeAssets with globPatterns: duplicate precache URLs make Workbox + // throw add-to-cache-list-conflicting-entries and the SW caches nothing. + includeManifestIcons: false, + // Same fields as the Angular app's src/manifest.json. + manifest: { + name: 'Angular 2 HN', + short_name: 'Angular 2 HN', + icons: [144, 192, 256, 512].map((size) => ({ + src: `assets/icons/android-chrome-${size}x${size}.png`, + sizes: `${size}x${size}`, + type: 'image/png', + })), + theme_color: '#b92b27', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait', + start_url: './?utm_source=web_app_manifest', + }, + workbox: { + // App shell: JS/CSS/HTML/icons/svg. manifest.webmanifest is added by the plugin. + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + // Retires the Angular app's service worker; see public/ngsw-worker.js. + globIgnores: ['ngsw-worker.js'], + navigateFallback: 'index.html', + // Firebase reserved URLs and file requests must not get the app shell. + navigateFallbackDenylist: [/^\/__\//, /\/[^/?]+\.[^/]+$/], + cleanupOutdatedCaches: true, + // autoUpdate only sets these itself when injectRegister is 'auto'. + skipWaiting: true, + clientsClaim: true, + runtimeCaching: API_CACHES.map(({ origin, cacheName, maxEntries }) => ({ + urlPattern: new RegExp(`^${origin.replace(/[.]/g, '\\.')}/`), + handler: 'NetworkFirst' as const, + options: { + cacheName, + networkTimeoutSeconds: 5, + expiration: { maxEntries, maxAgeSeconds: API_CACHE_MAX_AGE_SECONDS }, + cacheableResponse: { statuses: [200] }, + }, + })), + }, + }), + ], server: { port: 4200, },