forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import process from 'node:process'
2import { currentLocales } from './config/i18n'
3import { isCI, provider } from 'std-env'
4
5export default defineNuxtConfig({
6 modules: [
7 '@unocss/nuxt',
8 '@nuxtjs/html-validator',
9 '@nuxt/scripts',
10 '@nuxt/a11y',
11 '@nuxt/fonts',
12 'nuxt-og-image',
13 '@nuxt/test-utils',
14 '@vite-pwa/nuxt',
15 '@vueuse/nuxt',
16 '@nuxtjs/i18n',
17 '@nuxtjs/color-mode',
18 ],
19
20 $test: {
21 debug: {
22 hydration: true,
23 },
24 },
25
26 colorMode: {
27 preference: 'system',
28 fallback: 'dark',
29 dataValue: 'theme',
30 storageKey: 'npmx-color-mode',
31 },
32
33 css: ['~/assets/main.css', 'vue-data-ui/style.css'],
34
35 runtimeConfig: {
36 sessionPassword: '',
37 github: {
38 orgToken: '',
39 },
40 // Upstash Redis for distributed OAuth token refresh locking in production
41 upstash: {
42 redisRestUrl: process.env.UPSTASH_KV_REST_API_URL || process.env.KV_REST_API_URL || '',
43 redisRestToken: process.env.UPSTASH_KV_REST_API_TOKEN || process.env.KV_REST_API_TOKEN || '',
44 },
45 public: {
46 // Algolia npm-search index (maintained by Algolia & jsDelivr, used by yarnpkg.com et al.)
47 algolia: {
48 appId: 'OFCNCOG2CU',
49 apiKey: 'f54e21fa3a2a0160595bb058179bfb1e',
50 indexName: 'npm-search',
51 },
52 },
53 },
54
55 devtools: { enabled: true },
56
57 devServer: {
58 // Used with atproto oauth
59 // https://atproto.com/specs/oauth#localhost-client-development
60 host: '127.0.0.1',
61 },
62
63 app: {
64 head: {
65 htmlAttrs: { lang: 'en-US' },
66 title: 'npmx',
67 link: [
68 {
69 rel: 'search',
70 type: 'application/opensearchdescription+xml',
71 title: 'npm',
72 href: '/opensearch.xml',
73 },
74 ],
75 meta: [{ name: 'twitter:card', content: 'summary_large_image' }],
76 },
77 },
78
79 vue: {
80 compilerOptions: {
81 isCustomElement: tag => tag === 'search',
82 },
83 },
84
85 site: {
86 url: 'https://npmx.dev',
87 name: 'npmx',
88 description: 'A fast, modern browser for the npm registry',
89 },
90
91 router: {
92 options: {
93 scrollBehaviorType: 'smooth',
94 },
95 },
96
97 routeRules: {
98 // API routes
99 '/api/**': { isr: 60 },
100 '/api/registry/badge/**': {
101 isr: {
102 expiration: 60 * 60 /* one hour */,
103 passQuery: true,
104 allowQuery: ['color', 'labelColor', 'label', 'name'],
105 },
106 },
107 '/api/registry/downloads/**': {
108 isr: {
109 expiration: 60 * 60 /* one hour */,
110 passQuery: true,
111 allowQuery: ['mode', 'filterOldVersions', 'filterThreshold'],
112 },
113 },
114 '/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
115 '/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
116 '/api/registry/provenance/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
117 '/api/registry/files/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
118 '/api/registry/package-meta/**': { isr: 300 },
119 '/:pkg/.well-known/skills/**': { isr: 3600 },
120 '/:scope/:pkg/.well-known/skills/**': { isr: 3600 },
121 '/__og-image__/**': getISRConfig(60),
122 '/_avatar/**': { isr: 3600, proxy: 'https://www.gravatar.com/avatar/**' },
123 '/opensearch.xml': { isr: true },
124 '/oauth-client-metadata.json': { prerender: true },
125 // never cache
126 '/api/auth/**': { isr: false, cache: false },
127 '/api/social/**': { isr: false, cache: false },
128 '/api/opensearch/suggestions': {
129 isr: {
130 expiration: 60 * 60 * 24 /* one day */,
131 passQuery: true,
132 allowQuery: ['q'],
133 },
134 },
135 // pages
136 '/package/:name': getISRConfig(60, { fallback: 'html' }),
137 '/package/:name/_payload.json': getISRConfig(60, { fallback: 'json' }),
138 '/package/:name/v/:version': getISRConfig(60, { fallback: 'html' }),
139 '/package/:name/v/:version/_payload.json': getISRConfig(60, { fallback: 'json' }),
140 '/package/:org/:name': getISRConfig(60, { fallback: 'html' }),
141 '/package/:org/:name/_payload.json': getISRConfig(60, { fallback: 'json' }),
142 '/package/:org/:name/v/:version': getISRConfig(60, { fallback: 'html' }),
143 '/package/:org/:name/v/:version/_payload.json': getISRConfig(60, { fallback: 'json' }),
144 // infinite cache (versioned - doesn't change)
145 '/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
146 '/package-docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
147 // static pages
148 '/': { prerender: true },
149 '/200.html': { prerender: true },
150 '/about': { prerender: true },
151 '/accessibility': { prerender: true },
152 '/privacy': { prerender: true },
153 '/search': { isr: false, cache: false }, // never cache
154 '/settings': { prerender: true },
155 '/recharging': { prerender: true },
156 // proxy for insights
157 '/_v/script.js': { proxy: 'https://npmx.dev/_vercel/insights/script.js' },
158 '/_v/view': { proxy: 'https://npmx.dev/_vercel/insights/view' },
159 '/_v/event': { proxy: 'https://npmx.dev/_vercel/insights/event' },
160 '/_v/session': { proxy: 'https://npmx.dev/_vercel/insights/session' },
161 // lunaria status.json
162 '/lunaria/status.json': {
163 headers: {
164 'Cache-Control': 'public, max-age=0, must-revalidate',
165 },
166 },
167 },
168
169 experimental: {
170 entryImportMap: false,
171 typescriptPlugin: true,
172 viteEnvironmentApi: true,
173 typedPages: true,
174 },
175
176 compatibilityDate: '2026-01-31',
177
178 nitro: {
179 externals: {
180 inline: [
181 'shiki',
182 '@shikijs/langs',
183 '@shikijs/themes',
184 '@shikijs/types',
185 '@shikijs/engine-javascript',
186 '@shikijs/core',
187 ],
188 external: ['@deno/doc'],
189 },
190 esbuild: {
191 options: {
192 target: 'es2024',
193 },
194 },
195 rollupConfig: {
196 output: {
197 paths: {
198 '@deno/doc': '@jsr/deno__doc',
199 },
200 },
201 },
202 // Storage configuration for local development
203 // In production (Vercel), this is overridden by modules/cache.ts
204 storage: {
205 'fetch-cache': {
206 driver: 'fsLite',
207 base: './.cache/fetch',
208 },
209 'atproto': {
210 driver: 'fsLite',
211 base: './.cache/atproto',
212 },
213 },
214 typescript: {
215 tsConfig: {
216 include: ['../test/unit/server/**/*.ts'],
217 },
218 },
219 },
220
221 fonts: {
222 families: [
223 {
224 name: 'Geist',
225 weights: ['400', '500', '600'],
226 preload: true,
227 global: true,
228 },
229 {
230 name: 'Geist Mono',
231 weights: ['400', '500'],
232 preload: true,
233 global: true,
234 },
235 ],
236 },
237
238 htmlValidator: {
239 enabled: !isCI || (provider !== 'vercel' && !!process.env.VALIDATE_HTML),
240 options: {
241 rules: { 'meta-refresh': 'off' },
242 },
243 failOnError: true,
244 },
245
246 ogImage: {
247 defaults: {
248 component: 'Default',
249 },
250 fonts: [
251 { name: 'Geist', weight: 400, path: '/fonts/Geist-Regular.ttf' },
252 { name: 'Geist', weight: 500, path: '/fonts/Geist-Medium.ttf' },
253 { name: 'Geist', weight: 600, path: '/fonts/Geist-SemiBold.ttf' },
254 { name: 'Geist', weight: 700, path: '/fonts/Geist-Bold.ttf' },
255 { name: 'Geist Mono', weight: 400, path: '/fonts/GeistMono-Regular.ttf' },
256 { name: 'Geist Mono', weight: 500, path: '/fonts/GeistMono-Medium.ttf' },
257 { name: 'Geist Mono', weight: 700, path: '/fonts/GeistMono-Bold.ttf' },
258 ],
259 },
260
261 pwa: {
262 // Disable service worker
263 disable: true,
264 pwaAssets: {
265 config: false,
266 },
267 manifest: {
268 name: 'npmx',
269 short_name: 'npmx',
270 description: 'A fast, modern browser for the npm registry',
271 theme_color: '#0a0a0a',
272 background_color: '#0a0a0a',
273 icons: [
274 {
275 src: 'pwa-64x64.png',
276 sizes: '64x64',
277 type: 'image/png',
278 },
279 {
280 src: 'pwa-192x192.png',
281 sizes: '192x192',
282 type: 'image/png',
283 },
284 {
285 src: 'pwa-512x512.png',
286 sizes: '512x512',
287 type: 'image/png',
288 purpose: 'any',
289 },
290 {
291 src: 'maskable-icon-512x512.png',
292 sizes: '512x512',
293 type: 'image/png',
294 purpose: 'maskable',
295 },
296 ],
297 },
298 },
299
300 typescript: {
301 tsConfig: {
302 compilerOptions: {
303 noUnusedLocals: true,
304 allowImportingTsExtensions: true,
305 paths: {
306 '#cli/*': ['../cli/src/*'],
307 },
308 },
309 include: ['../test/unit/app/**/*.ts'],
310 },
311 sharedTsConfig: {
312 include: ['../test/unit/shared/**/*.ts'],
313 },
314 nodeTsConfig: {
315 compilerOptions: {
316 allowImportingTsExtensions: true,
317 paths: {
318 '#cli/*': ['../cli/src/*'],
319 '#server/*': ['../server/*'],
320 '#shared/*': ['../shared/*'],
321 },
322 },
323 include: ['../*.ts', '../test/e2e/**/*.ts'],
324 },
325 },
326
327 vite: {
328 optimizeDeps: {
329 include: [
330 '@vueuse/core',
331 '@vueuse/integrations/useFocusTrap',
332 '@vueuse/integrations/useFocusTrap/component',
333 'vue-data-ui/vue-ui-sparkline',
334 'vue-data-ui/vue-ui-xy',
335 'virtua/vue',
336 'semver',
337 'validate-npm-package-name',
338 '@atproto/lex',
339 'fast-npm-meta',
340 '@floating-ui/vue',
341 'algoliasearch/lite',
342 ],
343 },
344 },
345
346 i18n: {
347 locales: currentLocales,
348 defaultLocale: 'en-US',
349 strategy: 'no_prefix',
350 detectBrowserLanguage: false,
351 langDir: 'locales',
352 },
353
354 imports: {
355 dirs: ['~/composables', '~/composables/*/*.ts'],
356 },
357})
358
359interface ISRConfigOptions {
360 fallback?: 'html' | 'json'
361}
362function getISRConfig(expirationSeconds: number, options: ISRConfigOptions = {}) {
363 if (options.fallback) {
364 return {
365 isr: {
366 expiration: expirationSeconds,
367 fallback:
368 options.fallback === 'html' ? 'spa.prerender-fallback.html' : 'payload-fallback.json',
369 initialHeaders: options.fallback === 'json' ? { 'content-type': 'application/json' } : {},
370 } as { expiration: number },
371 }
372 }
373 return {
374 isr: {
375 expiration: expirationSeconds,
376 },
377 }
378}