diff --git a/app/service-worker.ts b/app/service-worker.ts new file mode 100644 index 000000000..71fbecf8c --- /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 ff10e62c9..ce2505927 100644 --- a/knip.ts +++ b/knip.ts @@ -13,6 +13,7 @@ const config: KnipConfig = { 'app/middleware/**/*.ts!', 'app/plugins/**/*.ts!', 'app/utils/**/*.ts!', + 'app/service-worker.ts!', 'server/**/*.ts!', 'modules/**/*.ts!', 'config/**/*.ts!', @@ -34,6 +35,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 a4baaf79d..883285300 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -114,6 +114,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', + }, + }, }, experimental: { @@ -192,18 +199,51 @@ export default defineNuxtConfig({ }, }, + $test: { + pwa: { + disable: true, + }, + }, + pwa: { - // Disable service worker - disable: true, - pwaAssets: { - 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 c8ec225ed..9dd63db9f 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", @@ -71,7 +73,7 @@ "@unocss/preset-wind4": "66.6.0", "@upstash/redis": "1.36.1", "@vite-pwa/assets-generator": "1.0.2", - "@vite-pwa/nuxt": "1.1.0", + "@vite-pwa/nuxt": "1.1.1", "@voidzero-dev/vite-plus-core": "0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab", "@vueuse/core": "14.2.0", "@vueuse/integrations": "14.2.0", @@ -105,7 +107,14 @@ "vite-plugin-pwa": "1.2.0", "vite-plus": "0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab", "vue": "3.5.27", - "vue-data-ui": "3.14.9" + "vue-data-ui": "3.14.9", + "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.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50953c709..4f19a68f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,8 +102,8 @@ importers: specifier: 1.0.2 version: 1.0.2 '@vite-pwa/nuxt': - specifier: 1.1.0 - version: 1.1.0(@vite-pwa/assets-generator@1.0.2)(magicast@0.5.1)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0) + specifier: 1.1.1 + version: 1.1.1(@vite-pwa/assets-generator@1.0.2)(magicast@0.5.1)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0) '@voidzero-dev/vite-plus-core': specifier: 0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab version: 0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab(@types/node@24.10.9)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2) @@ -206,6 +206,27 @@ importers: vue-data-ui: specifier: 3.14.9 version: 3.14.9(vue@3.5.27(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.1.4 @@ -4110,8 +4131,8 @@ packages: engines: {node: '>=16.14.0'} hasBin: true - '@vite-pwa/nuxt@1.1.0': - resolution: {integrity: sha512-OKrqHg9PHCqp9dlrtCaLlh55V0xEG/zkXjvpl2nE+6IB3xW8mqnH0hXYc1pjN7qv0JzB+lbCfWxFsg5EZvAjWA==} + '@vite-pwa/nuxt@1.1.1': + resolution: {integrity: sha512-8RkdxNJ3NubtwmS96bPVvDclFmysH3VADKWqlaBaUu6PLsoHM3Wq7ThdYBsAT6EpqzWS+iFC2SF6lkJ4xDhKLA==} peerDependencies: '@vite-pwa/assets-generator': ^1.0.0 peerDependenciesMeta: @@ -14012,7 +14033,7 @@ snapshots: sharp-ico: 0.1.5 unconfig: 7.4.2 - '@vite-pwa/nuxt@1.1.0(@vite-pwa/assets-generator@1.0.2)(magicast@0.5.1)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)': + '@vite-pwa/nuxt@1.1.1(@vite-pwa/assets-generator@1.0.2)(magicast@0.5.1)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)': dependencies: '@nuxt/kit': 3.21.0(magicast@0.5.1) pathe: 1.1.2 diff --git a/server/middleware/canonical-redirects.global.ts b/server/middleware/canonical-redirects.global.ts index 3cf5fe228..76645cf93 100644 --- a/server/middleware/canonical-redirects.global.ts +++ b/server/middleware/canonical-redirects.global.ts @@ -24,6 +24,7 @@ const pages = [ '/privacy', '/search', '/settings', + '/manifest.webmanifest', ] const cacheControl = 's-maxage=3600, stale-while-revalidate=36000'