import { mkdir, readFile, writeFile } from 'node:fs/promises' import type { Buffer } from 'node:buffer' import { dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { addPlugin, createResolver, defineNuxtModule } from '@nuxt/kit' import type { VitePluginPWAAPI } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa' import type { Plugin } from 'vite' import { join, resolve } from 'pathe' import type { VitePWANuxtOptions } from './types' import { configurePWAOptions } from './config' import { type LocalizedWebManifest, createI18n, pwaLocales } from './i18n' export * from './types' interface PwaDevIcon { src: string type: string } interface ResolvedPwaDevIcon extends PwaDevIcon { data: Promise } export default defineNuxtModule({ meta: { name: 'elk-pwa', configKey: 'pwa', }, defaults: nuxt => ({ base: nuxt.options.app.baseURL, scope: nuxt.options.app.baseURL, }), async setup(options, nuxt) { const resolver = createResolver(import.meta.url) let vitePwaClientPlugin: Plugin | undefined const resolveVitePluginPWAAPI = (): VitePluginPWAAPI | undefined => { return vitePwaClientPlugin?.api } let webmanifests: LocalizedWebManifest | undefined nuxt.options.appConfig = nuxt.options.appConfig || {} nuxt.options.appConfig.pwaEnabled = !options.disable nuxt.options.nitro.publicAssets = nuxt.options.nitro.publicAssets || [] const manifestDir = join(nuxt.options.buildDir, 'manifests') nuxt.options.nitro.publicAssets.push({ dir: manifestDir, baseURL: '/', maxAge: 0, }) if (options.disable) { addPlugin({ src: resolver.resolve('./runtime/pwa-plugin-stub.client') }) } else { // Register PWA types nuxt.hook('prepare:types', ({ references }) => { references.push({ types: 'vite-plugin-pwa/info' }) references.push({ types: 'vite-plugin-pwa/client' }) }) // Inject $pwa helper throughout app addPlugin({ src: resolver.resolve('./runtime/pwa-plugin.client') }) } // TODO: combine with configurePWAOptions? nuxt.hook('nitro:init', (nitro) => { options.outDir = nitro.options.output.publicDir options.injectManifest = options.injectManifest || {} options.injectManifest.globDirectory = nitro.options.output.publicDir }) nuxt.hook('vite:extend', ({ config }) => { const plugin = config.plugins?.find(p => p && typeof p === 'object' && 'name' in p && p.name === 'vite-plugin-pwa') if (plugin) throw new Error('Remove vite-plugin-pwa plugin from Vite Plugins entry in Nuxt config file!') }) nuxt.hook('vite:extendConfig', async (viteInlineConfig, { isClient }) => { viteInlineConfig.plugins = viteInlineConfig.plugins || [] const plugin = viteInlineConfig.plugins.find(p => p && typeof p === 'object' && 'name' in p && p.name === 'vite-plugin-pwa') if (plugin) throw new Error('Remove vite-plugin-pwa plugin from Vite Plugins entry in Nuxt config file!') webmanifests = await createI18n() const generateManifest = (entry: string) => { const manifest = webmanifests![entry] if (!manifest) throw new Error(`No webmanifest found for locale/theme ${entry}`) return JSON.stringify(manifest) } if (isClient) { viteInlineConfig.plugins.push({ name: 'elk:pwa:locales:build', apply: 'build', async writeBundle(_options, bundle) { if (options.disable || !bundle) return await mkdir(manifestDir, { recursive: true }) for (const wm in webmanifests) await writeFile(join(manifestDir, `manifest-${wm}.webmanifest`), generateManifest(wm)) }, }) } viteInlineConfig.plugins.push({ name: 'elk:pwa:dev', apply: 'serve', configureServer(server) { const icons: PwaDevIcon[] = webmanifests?.['en-US']?.icons as any const mappedIcons: PwaDevIcon[] = [ ...icons.map(({ src, type }) => ({ src, type })), { src: 'favicon.ico', type: 'image/x-icon' }, { src: 'apple-touch-icon.png', type: 'image/png' }, { src: 'logo.svg', type: 'image/svg+xml' }, ] const folder = dirname(fileURLToPath(import.meta.url)) const useIcons = mappedIcons.reduce((acc, icon) => { icon.src = `${nuxt.options.app.baseURL}${icon.src}` acc[icon.src] = { ...icon, data: readFile(resolve(join(folder, '../../public-dev', icon.src))), } return acc }, >{}) const localeMatcher = new RegExp(`^${nuxt.options.app.baseURL}manifest-(.*).webmanifest$`) server.middlewares.use(async (req, res, next) => { const url = req.url if (!url) return next() const match = url.match(localeMatcher) const entry = match && webmanifests![match[1]] if (entry) { res.statusCode = 200 res.setHeader('Content-Type', 'application/manifest+json') res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate') res.write(JSON.stringify(entry), 'utf-8') res.end() return } const icon = useIcons[url] if (!icon) return next() res.statusCode = 200 res.setHeader('Content-Type', icon.type) res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate') res.write(await icon.data) res.end() }) }, }) configurePWAOptions(options, nuxt) const plugins = VitePWA(options) viteInlineConfig.plugins.push(plugins) if (isClient) vitePwaClientPlugin = plugins.find(p => p.name === 'vite-plugin-pwa') as Plugin }) if (nuxt.options.dev) { const webManifest = `${nuxt.options.app.baseURL}${options.devOptions?.webManifestUrl ?? options.manifestFilename ?? 'manifest.webmanifest'}` const devSw = `${nuxt.options.app.baseURL}dev-sw.js?dev-sw` const workbox = `${nuxt.options.app.baseURL}workbox-` // @ts-expect-error just ignore const emptyHandle = (_req, _res, next) => { next() } nuxt.hook('vite:serverCreated', (viteServer, { isServer }) => { if (isServer) return viteServer.middlewares.stack.push({ route: webManifest, handle: emptyHandle }) if (webmanifests) { Object.keys(webmanifests).forEach((wm) => { viteServer.middlewares.stack.push({ route: `${nuxt.options.app.baseURL}manifest-${wm}.webmanifest`, handle: emptyHandle, }) }) } viteServer.middlewares.stack.push({ route: devSw, handle: emptyHandle }) }) if (!options.strategies || options.strategies === 'generateSW') { nuxt.hook('vite:serverCreated', (viteServer, { isServer }) => { if (isServer) return viteServer.middlewares.stack.push({ route: workbox, handle: emptyHandle }) }) nuxt.hook('close', async () => { // todo: cleanup dev-dist folder }) } } else { nuxt.hook('nitro:config', async (nitroConfig) => { nitroConfig.routeRules = nitroConfig.routeRules || {} nitroConfig.routeRules!['/sw.js'] = { headers: { 'Cache-Control': 'public, max-age=0, must-revalidate', }, } for (const locale of pwaLocales) { nitroConfig.routeRules![`/manifest-${locale.code}.webmanifest`] = { headers: { 'Content-Type': 'application/manifest+json', 'Cache-Control': 'public, max-age=0, must-revalidate', }, } nitroConfig.routeRules![`/manifest-${locale.code}-dark.webmanifest`] = { headers: { 'Content-Type': 'application/manifest+json', 'Cache-Control': 'public, max-age=0, must-revalidate', }, } } }) nuxt.hook('nitro:init', (nitro) => { nitro.hooks.hook('rollup:before', async () => { await resolveVitePluginPWAAPI()?.generateSW() }) }) } }, })