Skip to content
105 changes: 105 additions & 0 deletions app/service-worker.ts
Original file line number Diff line number Diff line change
@@ -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 }))
2 changes: 2 additions & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!',
Expand All @@ -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',

Expand Down
48 changes: 44 additions & 4 deletions nuxt.config.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a problem with the icon, it is square, but on other shapes of icons (for example, round ones), the edges are white, this needs to be fixed.

It would also be great to add a monochrome version of the icon to have this icon themed as well.

Screenshot_20260208_222757_Lawnchair.png

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to change the svg icons again, will be fixed once the service worker fixed (search page)

Original file line number Diff line number Diff line change
Expand Up @@ -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',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not some minimal caching, e.g.:

Suggested change
'Cache-Control': 'public, max-age=0, must-revalidate',
'Cache-Control': 'public, max-age=3600',

?

Copy link
Contributor Author

@userquin userquin Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The webmanifest should be always checked like the service worker. Once web manifest stable we can change the cache control header.

},
},
},

experimental: {
Expand Down Expand Up @@ -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',
Expand Down
13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
31 changes: 26 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/middleware/canonical-redirects.global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const pages = [
'/privacy',
'/search',
'/settings',
'/manifest.webmanifest',
]

const cacheControl = 's-maxage=3600, stale-while-revalidate=36000'
Expand Down
Loading