From 37dcddb1bb2df0b0ca66bf5686cfe200a3b5bd0d Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 8 Feb 2026 00:19:22 +0100 Subject: [PATCH 01/10] feat: add PWA web manifest (without service worker) --- nuxt.config.ts | 18 ++++++++++++++---- .../middleware/canonical-redirects.global.ts | 1 + 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index e25a9b79f..8037efff2 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: { @@ -193,10 +200,13 @@ export default defineNuxtConfig({ }, pwa: { - // Disable service worker - disable: true, - pwaAssets: { - config: false, + injectRegister: false, + client: { + // Disable service worker + registerPlugin: false, + }, + devOptions: { + enabled: true, }, manifest: { name: 'npmx', 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' From abe9cea023526dee9051047f26ad1e52c19f51f3 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 8 Feb 2026 02:04:04 +0100 Subject: [PATCH 02/10] chore: add service worker --- app/service-worker.ts | 97 +++++++++++++++++++++++++++++++++++++++++++ nuxt.config.ts | 38 +++++++++++++++-- package.json | 12 +++++- pnpm-lock.yaml | 31 +++++++++++--- 4 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 app/service-worker.ts diff --git a/app/service-worker.ts b/app/service-worker.ts new file mode 100644 index 000000000..7a98fa8e5 --- /dev/null +++ b/app/service-worker.ts @@ -0,0 +1,97 @@ +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$/, + // 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/'), + new NetworkFirst({ + cacheName: cacheNames[0], + plugins: [ + new CacheableResponsePlugin({ statuses: [200] }), + new ExpirationPlugin({ maxEntries: 100, 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: 100, 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/nuxt.config.ts b/nuxt.config.ts index 8037efff2..eb588d2e2 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -199,21 +199,51 @@ export default defineNuxtConfig({ }, }, + $test: { + pwa: { + disable: true, + }, + }, + pwa: { - injectRegister: false, + registerType: 'autoUpdate', + strategies: 'injectManifest', + srcDir: '.', + filename: 'service-worker.ts', client: { - // Disable service worker - registerPlugin: false, + installPrompt: true, + periodicSyncForUpdates: 3_600, // Check for updates every hour + }, + injectManifest: { + minify: 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: true, + 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 e0602c714..3c754d20f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:lunaria": "node ./lunaria/lunaria.ts", "build:test": "NODE_ENV=test pnpm 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 +72,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 +106,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 d6732b949..2f9e39705 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 @@ -4107,8 +4128,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: @@ -14009,7 +14030,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 From 2b9ac50b0e35b0d14b7c129b3ce0e000a7681f91 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 8 Feb 2026 02:30:29 +0100 Subject: [PATCH 03/10] chore: store also api calls to cache readme --- app/service-worker.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/service-worker.ts b/app/service-worker.ts index 7a98fa8e5..d524b4491 100644 --- a/app/service-worker.ts +++ b/app/service-worker.ts @@ -60,12 +60,13 @@ if (import.meta.env.PROD) { ] registerRoute( - ({ sameOrigin, url }) => sameOrigin && url.pathname.startsWith('/package/'), + ({ sameOrigin, url }) => + sameOrigin && (url.pathname.startsWith('/package/') || url.pathname.startsWith('/api/')), new NetworkFirst({ cacheName: cacheNames[0], plugins: [ new CacheableResponsePlugin({ statuses: [200] }), - new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 }), + new ExpirationPlugin({ maxEntries: 1000, maxAgeSeconds: 60 }), ], }), ) @@ -77,7 +78,7 @@ if (import.meta.env.PROD) { cacheName: cacheNames[1], plugins: [ new CacheableResponsePlugin({ statuses: [200] }), - new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 365 * 24 * 60 * 60 }), + new ExpirationPlugin({ maxEntries: 1000, maxAgeSeconds: 365 * 24 * 60 * 60 }), ], }), ) From 03daa2d3e8aa609b4e44104289f0d14daeed6844 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 8 Feb 2026 02:31:05 +0100 Subject: [PATCH 04/10] chore: add build local pwa with sw source and logs --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 3c754d20f..e50ed749f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "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", From f9cee4732666079d6683e485c78d5e8f4c58505d Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 8 Feb 2026 13:19:23 +0100 Subject: [PATCH 05/10] chore: add service-worker to knip entry --- knip.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.ts b/knip.ts index a36895280..f81421d75 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!', From b73b57d70bff72084aa32e0f597934ecc73e8965 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 8 Feb 2026 13:28:38 +0100 Subject: [PATCH 06/10] chore: add workbox-build to knip ignore deps --- knip.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.ts b/knip.ts index f81421d75..6e550e0cf 100644 --- a/knip.ts +++ b/knip.ts @@ -36,6 +36,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', From d7e9e789ec52cd8988e07dcf1b264697a50617d1 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 8 Feb 2026 16:45:35 +0100 Subject: [PATCH 07/10] chore: update denylist and runtime caching --- app/service-worker.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/service-worker.ts b/app/service-worker.ts index d524b4491..9d11c083f 100644 --- a/app/service-worker.ts +++ b/app/service-worker.ts @@ -47,6 +47,8 @@ if (import.meta.env.PROD) { denylist = [ // search page /^\/search$/, + /^\/~/, + /^\/org\//, // api calls /^\/api\//, /^\/oauth\//, @@ -61,7 +63,11 @@ if (import.meta.env.PROD) { registerRoute( ({ sameOrigin, url }) => - sameOrigin && (url.pathname.startsWith('/package/') || url.pathname.startsWith('/api/')), + sameOrigin && + (url.pathname.startsWith('/package/') || + url.pathname.startsWith('/org/') || + url.pathname.startsWith('/~') || + url.pathname.startsWith('/api/')), new NetworkFirst({ cacheName: cacheNames[0], plugins: [ From 4d02840504d3b84c4a64a72686bfeae776912dbd Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 8 Feb 2026 16:54:43 +0100 Subject: [PATCH 08/10] chore: disable install prompt --- nuxt.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index 723a07d0c..883285300 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -211,11 +211,11 @@ export default defineNuxtConfig({ srcDir: '.', filename: 'service-worker.ts', client: { - installPrompt: true, 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'], From 54ad77bddcd4efa2c3eeec2196380c943f947642 Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 8 Feb 2026 18:25:14 +0100 Subject: [PATCH 09/10] chore: don't intercept search? --- app/service-worker.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/service-worker.ts b/app/service-worker.ts index 9d11c083f..a1f561ec2 100644 --- a/app/service-worker.ts +++ b/app/service-worker.ts @@ -47,6 +47,7 @@ if (import.meta.env.PROD) { denylist = [ // search page /^\/search$/, + /^\/search?/, /^\/~/, /^\/org\//, // api calls From b6f5adef583e313709ff508e997420a05285d2ff Mon Sep 17 00:00:00 2001 From: userquin Date: Sun, 8 Feb 2026 18:36:04 +0100 Subject: [PATCH 10/10] chore: escape ? --- app/service-worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/service-worker.ts b/app/service-worker.ts index a1f561ec2..71fbecf8c 100644 --- a/app/service-worker.ts +++ b/app/service-worker.ts @@ -47,7 +47,7 @@ if (import.meta.env.PROD) { denylist = [ // search page /^\/search$/, - /^\/search?/, + /^\/search\?/, /^\/~/, /^\/org\//, // api calls