diff --git a/app/service-worker.ts b/app/service-worker.ts new file mode 100644 index 0000000000..71fbecf8c2 --- /dev/null +++ b/app/service-worker.ts @@ -0,0 +1,105 @@ +import { + cleanupOutdatedCaches, + createHandlerBoundToURL, + precacheAndRoute, +} from 'workbox-precaching' +import { clientsClaim } from 'workbox-core' +import { NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies' +import { NavigationRoute, registerRoute } from 'workbox-routing' +import { CacheableResponsePlugin } from 'workbox-cacheable-response' +import { ExpirationPlugin } from 'workbox-expiration' + +declare let self: ServiceWorkerGlobalScope + +const cacheNames = ['npmx-packages', 'npmx-packages-code-and-docs', 'npmx-vercel-proxies'] as const + +async function createRuntimeCaches() { + await Promise.all(cacheNames.map(c => caches.open(c))) +} +self.addEventListener('install', event => { + event.waitUntil(createRuntimeCaches()) +}) + +self.skipWaiting() +clientsClaim() + +cleanupOutdatedCaches() +precacheAndRoute(self.__WB_MANIFEST, { + urlManipulation: ({ url }) => { + const urls: URL[] = [] + // search use query params, we need to include here any page using query params + if (url.pathname.endsWith('_payload.json') || url.pathname.endsWith('/search')) { + const newUrl = new URL(url.href) + newUrl.search = '' + urls.push(newUrl) + } + return urls + }, +}) + +// allow only fallback in dev: we don't want to cache anything +let allowlist: undefined | RegExp[] +if (import.meta.env.DEV) allowlist = [/^\/$/] + +// deny api and server page calls +let denylist: undefined | RegExp[] +if (import.meta.env.PROD) { + denylist = [ + // search page + /^\/search$/, + /^\/search\?/, + /^\/~/, + /^\/org\//, + // api calls + /^\/api\//, + /^\/oauth\//, + /^\/package\//, + /^\/package-code\//, + /^\/package-docs\//, + /^\/_v\//, + /^\/opensearch\.xml$/, + // exclude sw: if the user navigates to it, fallback to index.html + /^\/service-worker\.js$/, + ] + + registerRoute( + ({ sameOrigin, url }) => + sameOrigin && + (url.pathname.startsWith('/package/') || + url.pathname.startsWith('/org/') || + url.pathname.startsWith('/~') || + url.pathname.startsWith('/api/')), + new NetworkFirst({ + cacheName: cacheNames[0], + plugins: [ + new CacheableResponsePlugin({ statuses: [200] }), + new ExpirationPlugin({ maxEntries: 1000, maxAgeSeconds: 60 }), + ], + }), + ) + registerRoute( + ({ sameOrigin, url }) => + sameOrigin && + (url.pathname.startsWith('/package-docs/') || url.pathname.startsWith('/package-code/')), + new StaleWhileRevalidate({ + cacheName: cacheNames[1], + plugins: [ + new CacheableResponsePlugin({ statuses: [200] }), + new ExpirationPlugin({ maxEntries: 1000, maxAgeSeconds: 365 * 24 * 60 * 60 }), + ], + }), + ) + registerRoute( + ({ sameOrigin, url }) => sameOrigin && url.pathname.startsWith('/_v/'), + new NetworkFirst({ + cacheName: cacheNames[1], + plugins: [ + new CacheableResponsePlugin({ statuses: [200] }), + new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 }), + ], + }), + ) +} + +// to allow work offline +registerRoute(new NavigationRoute(createHandlerBoundToURL('/'), { allowlist, denylist })) diff --git a/knip.ts b/knip.ts index a08df176b9..bf0f141d55 100644 --- a/knip.ts +++ b/knip.ts @@ -4,6 +4,7 @@ const config: KnipConfig = { workspaces: { '.': { entry: [ + 'app/service-worker.ts!', 'i18n/**/*.ts', 'lunaria.config.ts', 'pwa-assets.config.ts', @@ -26,6 +27,7 @@ const config: KnipConfig = { 'puppeteer', /** Needs to be explicitly installed, even though it is not imported, to avoid type errors. */ 'unplugin-vue-router', + 'workbox-build', 'vite-plugin-pwa', '@vueuse/shared', diff --git a/nuxt.config.ts b/nuxt.config.ts index 3a4c167ece..013440038f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -22,6 +22,9 @@ export default defineNuxtConfig({ debug: { hydration: true, }, + pwa: { + disable: true, + }, }, colorMode: { @@ -187,6 +190,13 @@ export default defineNuxtConfig({ '/_v/view': { proxy: 'https://npmx.dev/_vercel/insights/view' }, '/_v/event': { proxy: 'https://npmx.dev/_vercel/insights/event' }, '/_v/session': { proxy: 'https://npmx.dev/_vercel/insights/session' }, + // PWA manifest + '/manifest.webmanifest': { + headers: { + 'Content-Type': 'application/manifest+json', + 'Cache-Control': 'public, max-age=0, must-revalidate', + }, + }, // lunaria status.json '/lunaria/status.json': { headers: { @@ -299,18 +309,50 @@ export default defineNuxtConfig({ }, pwa: { - // Disable service worker - disable: true, + // Disable service worker for storybook + disable: isStorybook, pwaAssets: { disabled: isStorybook, config: false, }, + registerType: 'autoUpdate', + strategies: 'injectManifest', + srcDir: '.', + filename: 'service-worker.ts', + client: { + periodicSyncForUpdates: 3_600, // Check for updates every hour + }, + injectManifest: { + minify: process.env.VITE_DEV_PWA !== 'true', + sourcemap: process.env.VITE_DEV_PWA !== 'true', + enableWorkboxModulesLogs: process.env.VITE_DEV_PWA === 'true' ? true : undefined, + globPatterns: ['**/*.{js,json,css,html,txt,svg,png,ico,webp,woff,woff2,ttf,eot,otf,wasm}'], + globIgnores: ['manifest**.webmanifest'], + }, + devOptions: { + enabled: process.env.VITE_DEV_PWA === 'true', + type: 'module', + }, manifest: { + id: '/', + scope: '/', + start_url: '/', name: 'npmx', short_name: 'npmx', description: 'A fast, modern browser for the npm registry', theme_color: '#0a0a0a', background_color: '#0a0a0a', + orientation: 'portrait', + display: 'standalone', + display_override: ['window-controls-overlay'], + // categories: ['social', 'social networking', 'news'], + handle_links: 'preferred', + launch_handler: { + client_mode: ['navigate-existing', 'auto'], + }, + edge_side_panel: { + preferred_width: 480, + }, icons: [ { src: 'pwa-64x64.png', diff --git a/package.json b/package.json index a49549700e..8bee261629 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "build": "nuxt build", "build:lunaria": "node ./lunaria/lunaria.ts", "build:test": "NODE_ENV=test pnpm build", + "build:local:pwa": "VITE_DEV_PWA=true nuxt build", "dev": "nuxt dev", + "dev:pwa": "VITE_DEV_PWA=true nuxt dev", "dev:docs": "pnpm run --filter npmx-docs dev --port=3001", "i18n:check": "node scripts/compare-translations.ts", "i18n:check:fix": "node scripts/compare-translations.ts --fix", @@ -119,7 +121,14 @@ "vite-plugin-pwa": "1.2.0", "vite-plus": "0.0.0-g52709db6.20260226-1136", "vue": "3.5.30", - "vue-data-ui": "3.15.12" + "vue-data-ui": "3.15.12", + "workbox-build": "7.4.0", + "workbox-cacheable-response": "7.4.0", + "workbox-core": "7.4.0", + "workbox-expiration": "7.4.0", + "workbox-precaching": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" }, "devDependencies": { "@e18e/eslint-plugin": "0.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fde9c40a5..3bd7fc0901 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,6 +238,27 @@ importers: vue-data-ui: specifier: 3.15.12 version: 3.15.12(vue@3.5.30(typescript@5.9.3)) + workbox-build: + specifier: 7.4.0 + version: 7.4.0 + workbox-cacheable-response: + specifier: 7.4.0 + version: 7.4.0 + workbox-core: + specifier: 7.4.0 + version: 7.4.0 + workbox-expiration: + specifier: 7.4.0 + version: 7.4.0 + workbox-precaching: + specifier: 7.4.0 + version: 7.4.0 + workbox-routing: + specifier: 7.4.0 + version: 7.4.0 + workbox-strategies: + specifier: 7.4.0 + version: 7.4.0 devDependencies: '@e18e/eslint-plugin': specifier: 0.2.0 diff --git a/server/middleware/canonical-redirects.global.ts b/server/middleware/canonical-redirects.global.ts index c6f01ae1ff..85a4230adb 100644 --- a/server/middleware/canonical-redirects.global.ts +++ b/server/middleware/canonical-redirects.global.ts @@ -28,6 +28,7 @@ const pages = [ '/privacy', '/search', '/settings', + '/manifest.webmanifest', '/recharging', ]