├── hero.png ├── playground ├── .nuxtrc ├── tsconfig.json ├── public │ ├── favicon.ico │ ├── robots.txt │ ├── pwa-192x192.png │ ├── pwa-512x512.png │ └── favicon.svg ├── app.vue ├── pages │ ├── index.vue │ └── about.vue ├── package.json ├── plugins │ └── pwa.client.ts ├── service-worker │ └── sw.ts ├── layouts │ └── default.vue └── nuxt.config.ts ├── tsconfig.json ├── playground-assets ├── .nuxtrc ├── tsconfig.json ├── layouts │ └── default.vue ├── app.vue ├── package.json ├── pages │ ├── about.vue │ └── index.vue ├── pwa-assets.config.ts ├── nuxt.config.ts └── public │ └── favicon.svg ├── .npmrc ├── .nuxtrc ├── src ├── runtime │ ├── components │ │ ├── PwaAppleImage.vue.d.ts │ │ ├── PwaFaviconImage.vue.d.ts │ │ ├── PwaMaskableImage.vue.d.ts │ │ ├── PwaTransparentImage.vue.d.ts │ │ ├── PwaAppleSplashScreenImage.vue.d.ts │ │ ├── PwaAppleImage.vue │ │ ├── PwaFaviconImage.vue │ │ ├── PwaMaskableImage.vue │ │ ├── PwaTransparentImage.vue │ │ ├── PwaAppleSplashScreenImage.vue │ │ ├── nuxt4 │ │ │ ├── PwaAppleSplashScreenImage.ts │ │ │ ├── PwaFaviconImage.ts │ │ │ ├── PwaMaskableImage.ts │ │ │ ├── PwaTransparentImage.ts │ │ │ └── PwaAppleImage.ts │ │ ├── VitePwaManifest.ts │ │ └── NuxtPwaAssets.ts │ ├── composables │ │ └── index.ts │ └── plugins │ │ ├── types.d.ts │ │ └── pwa.client.ts ├── context.ts ├── utils │ ├── utils.ts │ ├── dev.ts │ ├── pwa-icons-types.ts │ ├── pwa-icons-helper.ts │ ├── pwa-icons.ts │ ├── config.ts │ └── module.ts ├── module.ts └── types.ts ├── vitest.config.mts ├── pnpm-workspace.yaml ├── .editorconfig ├── eslint.config.js ├── configuration.d.ts ├── .github ├── workflows │ ├── release.yml │ ├── cr-comment.yml │ └── cr.yml └── pull_request_template.md ├── .gitignore ├── LICENSE ├── CONTRIBUTING.md ├── client-test └── sw.spec.ts ├── test └── build.test.ts ├── playwright.config.ts ├── package.json └── README.md /hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vite-pwa/nuxt/HEAD/hero.png -------------------------------------------------------------------------------- /playground/.nuxtrc: -------------------------------------------------------------------------------- 1 | typescript.tsConfig.exclude[]=../src/runtime 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /playground-assets/.nuxtrc: -------------------------------------------------------------------------------- 1 | typescript.tsConfig.exclude[]=../src/runtime 2 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /playground-assets/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | shell-emulator=true 4 | -------------------------------------------------------------------------------- /.nuxtrc: -------------------------------------------------------------------------------- 1 | typescript.includeWorkspace=true 2 | typescript.tsConfig.exclude[]=../playground 3 | -------------------------------------------------------------------------------- /playground/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vite-pwa/nuxt/HEAD/playground/public/favicon.ico -------------------------------------------------------------------------------- /playground/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /playground/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vite-pwa/nuxt/HEAD/playground/public/pwa-192x192.png -------------------------------------------------------------------------------- /playground/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vite-pwa/nuxt/HEAD/playground/public/pwa-512x512.png -------------------------------------------------------------------------------- /playground-assets/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /playground-assets/app.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /src/runtime/components/PwaAppleImage.vue.d.ts: -------------------------------------------------------------------------------- 1 | const _default: typeof import('#build/pwa-icons/PwaAppleImage')['default'] 2 | export default _default 3 | -------------------------------------------------------------------------------- /src/runtime/components/PwaFaviconImage.vue.d.ts: -------------------------------------------------------------------------------- 1 | const _default: typeof import('#build/pwa-icons/PwaFaviconImage')['default'] 2 | export default _default 3 | -------------------------------------------------------------------------------- /src/runtime/components/PwaMaskableImage.vue.d.ts: -------------------------------------------------------------------------------- 1 | const _default: typeof import('#build/pwa-icons/PwaMaskableImage')['default'] 2 | export default _default 3 | -------------------------------------------------------------------------------- /src/runtime/components/PwaTransparentImage.vue.d.ts: -------------------------------------------------------------------------------- 1 | const _default: typeof import('#build/pwa-icons/PwaTransparentImage')['default'] 2 | export default _default 3 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['test/*.test.ts'], 6 | }, 7 | }) 8 | -------------------------------------------------------------------------------- /src/runtime/components/PwaAppleSplashScreenImage.vue.d.ts: -------------------------------------------------------------------------------- 1 | const _default: typeof import('#build/pwa-icons/PwaAppleSplashScreenImage')['default'] 2 | export default _default 3 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground/ 3 | - playground-assets/ 4 | ignoredBuiltDependencies: 5 | - esbuild 6 | - vue-demi 7 | onlyBuiltDependencies: 8 | - sharp 9 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default await antfu( 4 | { 5 | ignores: [ 6 | '**/build/**', 7 | '**/dist/**', 8 | '**/dev-dist/**', 9 | '**/node_modules/**', 10 | ], 11 | }, 12 | ) 13 | -------------------------------------------------------------------------------- /playground/pages/index.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /configuration.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'virtual:nuxt-pwa-configuration' { 2 | export const enabled: boolean 3 | export const display: 'fullscreen' | 'standalone' | 'minimal-ui' | 'browser' 4 | export const installPrompt: string | undefined 5 | export const periodicSyncForUpdates: number 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | uses: sxzz/workflows/.github/workflows/release.yml@v1 11 | with: 12 | publish: true 13 | permissions: 14 | contents: write 15 | id-token: write 16 | -------------------------------------------------------------------------------- /playground/pages/about.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import type { Resolver } from '@nuxt/kit' 2 | import type { Nuxt } from '@nuxt/schema' 3 | import type { PwaModuleOptions } from './types' 4 | 5 | export interface NuxtPWAContext { 6 | nuxt3_8: boolean 7 | nuxt4: boolean 8 | nuxt4Compat: boolean 9 | options: PwaModuleOptions 10 | nuxt: Nuxt 11 | resolver: Resolver 12 | publicDirFolder: string 13 | } 14 | -------------------------------------------------------------------------------- /src/runtime/components/PwaAppleImage.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /src/runtime/components/PwaFaviconImage.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /playground-assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground-assets", 3 | "type": "module", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "build": "nuxi build", 8 | "generate": "nuxi generate" 9 | }, 10 | "devDependencies": { 11 | "@vite-pwa/assets-generator": "^1.0.0", 12 | "@vite-pwa/nuxt": "workspace:*", 13 | "nuxt": "^3.10.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/runtime/components/PwaMaskableImage.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground", 3 | "type": "module", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "dev-sw": "SW=true nuxi dev", 8 | "build": "nuxi build", 9 | "build-sw": "SW=true nuxi build", 10 | "generate": "nuxi generate" 11 | }, 12 | "devDependencies": { 13 | "@vite-pwa/nuxt": "workspace:*", 14 | "nuxt": "^3.10.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/runtime/components/PwaTransparentImage.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /playground-assets/pages/about.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 18 | -------------------------------------------------------------------------------- /playground-assets/pages/index.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 18 | -------------------------------------------------------------------------------- /src/runtime/components/PwaAppleSplashScreenImage.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /playground/plugins/pwa.client.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin((nuxtApp) => { 2 | nuxtApp.hook('service-worker:registered', ({ url, registration }) => { 3 | // eslint-disable-next-line no-console 4 | console.log(`service worker registered at ${url}`, registration) 5 | }) 6 | nuxtApp.hook('service-worker:registration-failed', ({ error }) => { 7 | console.error(`service worker registration failed`, error) 8 | }) 9 | nuxtApp.hook('service-worker:activated', ({ url, registration }) => { 10 | // eslint-disable-next-line no-console 11 | console.log(`service worker activated at ${url}`, registration) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /src/runtime/components/nuxt4/PwaAppleSplashScreenImage.ts: -------------------------------------------------------------------------------- 1 | import type { PwaAppleImageProps } from '#build/pwa-icons/PwaAppleImage.d.ts' 2 | import { defineComponent, h } from '#imports' 3 | import { useApplePwaIcon } from '#pwa' 4 | 5 | export type { PwaAppleImageProps } 6 | 7 | export default defineComponent({ 8 | name: 'PwaAppleImage', 9 | inheritAttrs: false, 10 | setup(_, { attrs = {} }) { 11 | const { icon } = useApplePwaIcon(attrs as unknown as PwaAppleImageProps) 12 | return () => { 13 | const data = icon.value 14 | if (!data) 15 | return 16 | 17 | return h('img', data) 18 | } 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /src/runtime/components/nuxt4/PwaFaviconImage.ts: -------------------------------------------------------------------------------- 1 | import type { PwaFaviconImageProps } from '#build/pwa-icons/PwaFaviconImage.d.ts' 2 | import { defineComponent, h } from '#imports' 3 | import { useFaviconPwaIcon } from '#pwa' 4 | 5 | export type { PwaFaviconImageProps } 6 | 7 | export default defineComponent({ 8 | name: 'PwaFaviconImage', 9 | inheritAttrs: false, 10 | setup(_, { attrs = {} }) { 11 | const { icon } = useFaviconPwaIcon(attrs as unknown as PwaFaviconImageProps) 12 | return () => { 13 | const data = icon.value 14 | if (!data) 15 | return 16 | 17 | return h('img', data) 18 | } 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /src/runtime/components/nuxt4/PwaMaskableImage.ts: -------------------------------------------------------------------------------- 1 | import type { PwaMaskableImageProps } from '#build/pwa-icons/PwaMaskableImage.d.ts' 2 | import { defineComponent, h } from '#imports' 3 | import { useMaskablePwaIcon } from '#pwa' 4 | 5 | export type { PwaMaskableImageProps } 6 | 7 | export default defineComponent({ 8 | name: 'PwaMaskableImage', 9 | inheritAttrs: false, 10 | setup(_, { attrs = {} }) { 11 | const { icon } = useMaskablePwaIcon(attrs as unknown as PwaMaskableImageProps) 12 | return () => { 13 | const data = icon.value 14 | if (!data) 15 | return 16 | 17 | return h('img', data) 18 | } 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /src/runtime/components/nuxt4/PwaTransparentImage.ts: -------------------------------------------------------------------------------- 1 | import type { PwaTransparentImageProps } from '#build/pwa-icons/PwaTransparentImage.d.ts' 2 | import { defineComponent, h } from '#imports' 3 | import { useTransparentPwaIcon } from '#pwa' 4 | 5 | export type { PwaTransparentImageProps } 6 | 7 | export default defineComponent({ 8 | name: 'PwaTransparentImage', 9 | inheritAttrs: false, 10 | setup(_, { attrs = {} }) { 11 | const { icon } = useTransparentPwaIcon(attrs as unknown as PwaTransparentImageProps) 12 | return () => { 13 | const data = icon.value 14 | if (!data) 15 | return 16 | 17 | return h('img', data) 18 | } 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /.github/workflows/cr-comment.yml: -------------------------------------------------------------------------------- 1 | name: Add continuous release label 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | 7 | permissions: 8 | pull-requests: write 9 | 10 | jobs: 11 | label: 12 | if: ${{ github.event.issue.pull_request && (github.event.comment.user.id == github.event.issue.user.id || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR') && startsWith(github.event.comment.body, '/publish') }} 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - run: gh issue edit ${{ github.event.issue.number }} --add-label cr-tracked --repo ${{ github.repository }} 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.CR_PAT }} 19 | -------------------------------------------------------------------------------- /src/runtime/components/nuxt4/PwaAppleImage.ts: -------------------------------------------------------------------------------- 1 | import type { PwaAppleSplashScreenImageProps } from '#build/pwa-icons/PwaAppleSplashScreenImage.d.ts' 2 | import { defineComponent, h } from '#imports' 3 | import { useAppleSplashScreenPwaIcon } from '#pwa' 4 | 5 | export type { PwaAppleSplashScreenImageProps } 6 | 7 | export default defineComponent({ 8 | name: 'PwaAppleImage', 9 | inheritAttrs: false, 10 | setup(_, { attrs = {} }) { 11 | const { icon } = useAppleSplashScreenPwaIcon(attrs as unknown as PwaAppleSplashScreenImageProps) 12 | return () => { 13 | const data = icon.value 14 | if (!data) 15 | return 16 | 17 | return h('img', data) 18 | } 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /playground-assets/pwa-assets.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createAppleSplashScreens, 3 | defineConfig, 4 | minimal2023Preset, 5 | } from '@vite-pwa/assets-generator/config' 6 | 7 | export default defineConfig({ 8 | headLinkOptions: { 9 | preset: '2023', 10 | }, 11 | preset: { 12 | ...minimal2023Preset, 13 | appleSplashScreens: createAppleSplashScreens({ 14 | padding: 0.3, 15 | resizeOptions: { fit: 'contain', background: 'white' }, 16 | darkResizeOptions: { fit: 'contain', background: 'black' }, 17 | linkMediaOptions: { 18 | log: true, 19 | addMediaScreen: true, 20 | xhtml: true, 21 | }, 22 | }, ['iPad Air 9.7"']), 23 | }, 24 | images: 'public/favicon.svg', 25 | }) 26 | -------------------------------------------------------------------------------- /playground/service-worker/sw.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import { clientsClaim } from 'workbox-core' 4 | import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching' 5 | import { NavigationRoute, registerRoute } from 'workbox-routing' 6 | 7 | declare let self: ServiceWorkerGlobalScope 8 | 9 | // self.__WB_MANIFEST is default injection point 10 | precacheAndRoute(self.__WB_MANIFEST) 11 | 12 | // clean old assets 13 | cleanupOutdatedCaches() 14 | 15 | let allowlist: undefined | RegExp[] 16 | if (import.meta.env.DEV) 17 | allowlist = [/^\/$/] 18 | 19 | // to allow work offline 20 | registerRoute(new NavigationRoute( 21 | createHandlerBoundToURL('/'), 22 | { allowlist }, 23 | )) 24 | 25 | self.skipWaiting() 26 | clientsClaim() 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .vercel 23 | .build-* 24 | .env 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | dev-dist/ 36 | 37 | # VSCode 38 | .vscode/* 39 | !.vscode/settings.json 40 | !.vscode/tasks.json 41 | !.vscode/launch.json 42 | !.vscode/extensions.json 43 | !.vscode/*.code-snippets 44 | 45 | # Intellij idea 46 | *.iml 47 | .idea 48 | 49 | # OSX 50 | .DS_Store 51 | .AppleDouble 52 | .LSOverride 53 | .AppleDB 54 | .AppleDesktop 55 | Network Trash Folder 56 | Temporary Items 57 | .apdisk 58 | 59 | # Playwright test 60 | /test-results/ 61 | /playwright-report/ 62 | /playwright/.cache/ 63 | -------------------------------------------------------------------------------- /.github/workflows/cr.yml: -------------------------------------------------------------------------------- 1 | name: CR 2 | 3 | env: 4 | PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' 5 | 6 | on: 7 | pull_request: 8 | branches: [main] 9 | types: [opened, synchronize, labeled, ready_for_review] 10 | 11 | permissions: {} 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.event.number }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | release: 19 | if: ${{ !github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'cr-tracked') }} 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: pnpm/action-setup@v4.0.0 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version: 20 28 | registry-url: https://registry.npmjs.org/ 29 | cache: pnpm 30 | - run: pnpm install 31 | - run: pnpm prepack 32 | - run: pnpx pkg-pr-new publish --compact --no-template --pnpm 33 | -------------------------------------------------------------------------------- /src/runtime/components/VitePwaManifest.ts: -------------------------------------------------------------------------------- 1 | import type { MetaObject } from '@nuxt/schema' 2 | import { useHead } from '#imports' 3 | import { pwaInfo } from 'virtual:pwa-info' 4 | import { defineComponent, ref } from 'vue' 5 | 6 | export default defineComponent({ 7 | async setup() { 8 | if (pwaInfo) { 9 | const meta = ref({ link: [] }) 10 | useHead(meta) 11 | 12 | const { webManifest } = pwaInfo 13 | if (webManifest) { 14 | const { href, useCredentials } = webManifest 15 | if (useCredentials) { 16 | meta.value.link!.push({ 17 | rel: 'manifest', 18 | href, 19 | crossorigin: 'use-credentials', 20 | }) 21 | } 22 | else { 23 | meta.value.link!.push({ 24 | rel: 'manifest', 25 | href, 26 | }) 27 | } 28 | } 29 | } 30 | 31 | return () => null 32 | }, 33 | }) 34 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import type { VitePluginPWAAPI } from 'vite-plugin-pwa' 2 | import { writeFile } from 'node:fs/promises' 3 | import { resolve } from 'pathe' 4 | 5 | export async function regeneratePWA(_dir: string, pwaAssets: boolean, api?: VitePluginPWAAPI) { 6 | if (pwaAssets) { 7 | const pwaAssetsGenerator = await api?.pwaAssetsGenerator() 8 | if (pwaAssetsGenerator) 9 | await pwaAssetsGenerator.generate() 10 | } 11 | 12 | if (!api || api.disabled) 13 | return 14 | 15 | await api.generateSW() 16 | } 17 | 18 | export async function writeWebManifest(dir: string, path: string, api: VitePluginPWAAPI, pwaAssets: boolean) { 19 | if (pwaAssets) { 20 | const pwaAssetsGenerator = await api.pwaAssetsGenerator() 21 | if (pwaAssetsGenerator) 22 | pwaAssetsGenerator.injectManifestIcons() 23 | } 24 | const manifest = api.generateBundle({})?.[path] 25 | if (manifest && 'source' in manifest) 26 | await writeFile(resolve(dir, path), manifest.source, 'utf-8') 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-PRESENT Anthony Fu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | 3 | 4 | 5 | 16 | 17 | ### Linked Issues 18 | 19 | 20 | 21 | ### Additional Context 22 | 23 | 24 | 25 | --- 26 | 27 | > [!TIP] 28 | > The author of this PR can publish a _preview release_ by commenting `/publish` below. 29 | -------------------------------------------------------------------------------- /src/runtime/components/NuxtPwaAssets.ts: -------------------------------------------------------------------------------- 1 | import type { MetaObject } from '@nuxt/schema' 2 | import { useHead } from '#imports' 3 | import { pwaAssetsHead } from 'virtual:pwa-assets/head' 4 | import { pwaInfo } from 'virtual:pwa-info' 5 | import { defineComponent, ref } from 'vue' 6 | 7 | export default defineComponent({ 8 | setup() { 9 | const meta = ref({ link: [] }) 10 | useHead(meta) 11 | if (pwaAssetsHead.themeColor) 12 | meta.value.meta = [{ name: 'theme-color', content: pwaAssetsHead.themeColor.content }] 13 | 14 | if (pwaAssetsHead.links.length) 15 | // @ts-expect-error: links are fine 16 | meta.value.link!.push(...pwaAssetsHead.links) 17 | 18 | if (pwaInfo) { 19 | const { webManifest } = pwaInfo 20 | if (webManifest) { 21 | const { href, useCredentials } = webManifest 22 | if (useCredentials) { 23 | meta.value.link!.push({ 24 | rel: 'manifest', 25 | href, 26 | crossorigin: 'use-credentials', 27 | }) 28 | } 29 | else { 30 | meta.value.link!.push({ 31 | rel: 'manifest', 32 | href, 33 | }) 34 | } 35 | } 36 | } 37 | 38 | return () => null 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /playground-assets/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | /* ssr: false, */ 3 | // typescript, 4 | modules: ['@vite-pwa/nuxt'], 5 | future: { 6 | typescriptBundlerResolution: true, 7 | }, 8 | experimental: { 9 | payloadExtraction: true, 10 | watcher: 'parcel', 11 | }, 12 | nitro: { 13 | esbuild: { 14 | options: { 15 | target: 'esnext', 16 | }, 17 | }, 18 | prerender: { 19 | routes: ['/', '/about'], 20 | }, 21 | }, 22 | imports: { 23 | autoImport: true, 24 | }, 25 | appConfig: { 26 | // you don't need to include this: only for testing purposes 27 | buildDate: new Date().toISOString(), 28 | }, 29 | vite: { 30 | logLevel: 'info', 31 | }, 32 | pwa: { 33 | mode: 'development', 34 | strategies: 'generateSW', 35 | registerType: 'autoUpdate', 36 | manifest: { 37 | name: 'Nuxt Vite PWA', 38 | short_name: 'NuxtVitePWA', 39 | theme_color: '#ffffff', 40 | }, 41 | pwaAssets: { 42 | config: true, 43 | // config: false, 44 | // image: 'favicon.svg', 45 | }, 46 | workbox: { 47 | globPatterns: ['**/*.{js,css,html,png,svg,ico}'], 48 | }, 49 | client: { 50 | installPrompt: true, 51 | }, 52 | devOptions: { 53 | enabled: true, 54 | suppressWarnings: true, 55 | navigateFallback: '/', 56 | navigateFallbackAllowlist: [/^\/$/], 57 | }, 58 | }, 59 | }) 60 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | Hi! We are really excited that you are interested in contributing to `@vite-pwa/nuxt`. Before submitting your contribution, please make sure to take a moment and read through the following guide. 4 | 5 | Refer also to https://github.com/antfu/contribute. 6 | 7 | ## Set up your local development environment 8 | 9 | The `@vite-pwa/nuxt` repo is a monorepo using pnpm workspaces. The package manager used to install and link dependencies must be [pnpm](https://pnpm.io/). 10 | 11 | To develop and test the `@vite-pwa/nuxt` package: 12 | 13 | 1. Fork the `@vite-pwa/nuxt` repository to your own GitHub account and then clone it to your local device. 14 | 15 | 2. `@vite-pwa/nuxt` uses pnpm v8. If you are working on multiple projects with different versions of pnpm, it's recommend to enable [Corepack](https://github.com/nodejs/corepack) by running `corepack enable`. 16 | 17 | 3. Check out a branch where you can work and commit your changes: 18 | ```shell 19 | git checkout -b my-new-branch 20 | ``` 21 | 22 | 5. Run `pnpm install` in `@vite-pwa/nuxt`'s root folder 23 | 24 | 6. Run `nr dev:prepare` in `@vite-pwa/nuxt`'s root folder. 25 | 26 | 7. Run `nr dev` in `@vite-pwa/nuxt`'s root folder. 27 | 28 | ## Running tests 29 | 30 | Before running tests, you'll need to install [Playwright](https://playwright.dev/) Chromium browser: `pnpm playwright install chromium`. 31 | 32 | Run `nr test:local` in `@vite-pwa/nuxt`'s root folder. 33 | -------------------------------------------------------------------------------- /src/utils/dev.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import { join } from 'node:path' 3 | import { eventHandler } from 'h3' 4 | 5 | export function dev( 6 | swMap: string, 7 | resolvedSwMapFile: string, 8 | worboxMap: string, 9 | buildDir: string, 10 | baseURL: string, 11 | ) { 12 | return eventHandler(async (event) => { 13 | const url = event.path 14 | if (!url) 15 | return 16 | 17 | const file = url === swMap 18 | ? resolvedSwMapFile 19 | : url.startsWith(worboxMap) && url.endsWith('.js.map') 20 | ? join( 21 | buildDir, 22 | 'dev-sw-dist', 23 | url.slice(baseURL.length), 24 | ) 25 | : undefined 26 | 27 | if (file) { 28 | try { 29 | await waitFor(() => fs.existsSync(file)) 30 | const map = fs.readFileSync(file, 'utf-8') 31 | event.headers.set('Content-Type', 'application/json') 32 | event.headers.set('Cache-Control', 'public, max-age=0, must-revalidate') 33 | event.headers.set('Content-Length', `${map.length}`) 34 | event.node.res.end(map) 35 | } 36 | catch { 37 | } 38 | } 39 | }) 40 | } 41 | 42 | async function waitFor(method: () => boolean, retries = 5): Promise { 43 | if (method()) 44 | return 45 | 46 | if (retries === 0) 47 | throw new Error('Timeout in waitFor') 48 | 49 | await new Promise(resolve => setTimeout(resolve, 300)) 50 | 51 | return waitFor(method, retries - 1) 52 | } 53 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import type { HookResult } from '@nuxt/schema' 2 | import type { PwaModuleHooks, PwaModuleOptions } from './types' 3 | import { defineNuxtModule } from '@nuxt/kit' 4 | import { version } from '../package.json' 5 | import { doSetup } from './utils/module' 6 | 7 | export * from './types' 8 | 9 | export interface ModuleOptions extends PwaModuleOptions {} 10 | 11 | export interface ModuleHooks extends PwaModuleHooks {} 12 | 13 | export interface ModuleRuntimeHooks { 14 | /** 15 | * Emitted when the service worker is registered 16 | * @param data The url and the optional service worker registration object 17 | */ 18 | 'service-worker:registered': (data: { 19 | url: string 20 | registration?: ServiceWorkerRegistration 21 | }) => HookResult 22 | /** 23 | * Emitted when the service worker registration fails 24 | * @param data The optional error object 25 | */ 26 | 'service-worker:registration-failed': (data: { 27 | error?: unknown 28 | }) => HookResult 29 | /** 30 | * Emitted when the service worker is activated 31 | * @param data The url and the service worker registration object 32 | */ 33 | 'service-worker:activated': (data: { 34 | url: string 35 | registration: ServiceWorkerRegistration 36 | }) => HookResult 37 | } 38 | 39 | export default defineNuxtModule({ 40 | meta: { 41 | name: 'pwa', 42 | configKey: 'pwa', 43 | compatibility: { 44 | nuxt: '>=3.6.5', 45 | }, 46 | version, 47 | }, 48 | defaults: nuxt => ({ 49 | base: nuxt.options.app.baseURL, 50 | scope: nuxt.options.app.baseURL, 51 | injectRegister: false, 52 | includeManifestIcons: false, 53 | registerPlugin: true, 54 | writePlugin: false, 55 | client: { 56 | registerPlugin: true, 57 | installPrompt: false, 58 | periodicSyncForUpdates: 0, 59 | }, 60 | }), 61 | async setup(options, nuxt) { 62 | await doSetup(options, nuxt) 63 | }, 64 | }) 65 | -------------------------------------------------------------------------------- /playground/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 54 | 55 | 79 | -------------------------------------------------------------------------------- /client-test/sw.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test('The service worker is registered and cache storage is present', async ({ page }) => { 4 | await page.goto('/') 5 | 6 | const swURL = await page.evaluate(async () => { 7 | const registration = await Promise.race([ 8 | navigator.serviceWorker.ready, 9 | new Promise((_resolve, reject) => setTimeout(() => reject(new Error('Service worker registration failed: time out')), 10000)), 10 | ]) 11 | // @ts-expect-error TS18046: 'registration' is of type 'unknown'. 12 | return registration.active?.scriptURL 13 | }) 14 | const swName = 'sw.js' 15 | expect(swURL).toBe(`http://localhost:4173/${swName}`) 16 | 17 | const cacheContents = await page.evaluate(async () => { 18 | const cacheState: Record> = {} 19 | for (const cacheName of await caches.keys()) { 20 | const cache = await caches.open(cacheName) 21 | cacheState[cacheName] = (await cache.keys()).map(req => req.url) 22 | } 23 | return cacheState 24 | }) 25 | 26 | expect(Object.keys(cacheContents).length).toEqual(1) 27 | 28 | const key = 'workbox-precache-v2-http://localhost:4173/' 29 | 30 | expect(Object.keys(cacheContents)[0]).toEqual(key) 31 | 32 | const urls = cacheContents[key].map(url => url.slice('http://localhost:4173/'.length)) 33 | 34 | /* 35 | 'http://localhost:4173/about?__WB_REVISION__=38251751d310c9b683a1426c22c135a2', 36 | 'http://localhost:4173/?__WB_REVISION__=073370aa3804305a787b01180cd6b8aa', 37 | 'http://localhost:4173/manifest.webmanifest?__WB_REVISION__=27df2fa4f35d014b42361148a2207da3' 38 | */ 39 | expect(urls.some(url => url.startsWith('manifest.webmanifest?__WB_REVISION__='))).toEqual(true) 40 | expect(urls.some(url => url.startsWith('?__WB_REVISION__='))).toEqual(true) 41 | expect(urls.some(url => url.startsWith('about?__WB_REVISION__='))).toEqual(true) 42 | // dontCacheBustURLsMatching: any asset in _nuxt folder shouldn't have a revision (?__WB_REVISION__=) 43 | expect(urls.some(url => url.startsWith('_nuxt/') && url.endsWith('.css'))).toEqual(true) 44 | expect(urls.some(url => url.startsWith('_nuxt/') && url.endsWith('.js'))).toEqual(true) 45 | expect(urls.some(url => url.includes('_payload.json?__WB_REVISION__='))).toEqual(true) 46 | expect(urls.some(url => url.startsWith('_nuxt/builds/') && url.includes('.json'))).toEqual(true) 47 | }) 48 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process' 2 | 3 | const sw = process.env.SW === 'true' 4 | 5 | export default defineNuxtConfig({ 6 | /* ssr: false, */ 7 | // typescript, 8 | modules: [ 9 | '@vite-pwa/nuxt', 10 | (_, nuxt) => { 11 | nuxt.hook('pwa:beforeBuildServiceWorker', (options) => { 12 | console.log('pwa:beforeBuildServiceWorker: ', options.base) 13 | }) 14 | }, 15 | ], 16 | future: { 17 | typescriptBundlerResolution: true, 18 | }, 19 | experimental: { 20 | payloadExtraction: true, 21 | watcher: 'parcel', 22 | }, 23 | nitro: { 24 | esbuild: { 25 | options: { 26 | target: 'esnext', 27 | }, 28 | }, 29 | prerender: { 30 | routes: ['/', '/about'], 31 | }, 32 | }, 33 | imports: { 34 | autoImport: true, 35 | }, 36 | appConfig: { 37 | // you don't need to include this: only for testing purposes 38 | buildDate: new Date().toISOString(), 39 | }, 40 | vite: { 41 | logLevel: 'info', 42 | }, 43 | pwa: { 44 | strategies: sw ? 'injectManifest' : 'generateSW', 45 | srcDir: sw ? 'service-worker' : undefined, 46 | filename: sw ? 'sw.ts' : undefined, 47 | registerType: 'autoUpdate', 48 | manifest: { 49 | name: 'Nuxt Vite PWA', 50 | short_name: 'NuxtVitePWA', 51 | theme_color: '#ffffff', 52 | icons: [ 53 | { 54 | src: 'pwa-192x192.png', 55 | sizes: '192x192', 56 | type: 'image/png', 57 | }, 58 | { 59 | src: 'pwa-512x512.png', 60 | sizes: '512x512', 61 | type: 'image/png', 62 | }, 63 | { 64 | src: 'pwa-512x512.png', 65 | sizes: '512x512', 66 | type: 'image/png', 67 | purpose: 'any maskable', 68 | }, 69 | ], 70 | }, 71 | workbox: { 72 | globPatterns: ['**/*.{js,css,html,png,svg,ico}'], 73 | }, 74 | injectManifest: { 75 | globPatterns: ['**/*.{js,css,html,png,svg,ico}'], 76 | }, 77 | client: { 78 | installPrompt: true, 79 | // you don't need to include this: only for testing purposes 80 | // if enabling periodic sync for update use 1 hour or so (periodicSyncForUpdates: 3600) 81 | periodicSyncForUpdates: 20, 82 | }, 83 | experimental: { 84 | includeAllowlist: true, 85 | }, 86 | devOptions: { 87 | enabled: true, 88 | suppressWarnings: true, 89 | navigateFallback: '/', 90 | navigateFallbackAllowlist: [/^\/$/], 91 | type: 'module', 92 | }, 93 | }, 94 | }) 95 | -------------------------------------------------------------------------------- /src/utils/pwa-icons-types.ts: -------------------------------------------------------------------------------- 1 | import type { NuxtPWAContext } from '../context' 2 | import type { DtsInfo } from './pwa-icons-helper' 3 | import { addImports, addTypeTemplate } from '@nuxt/kit' 4 | import { addPwaTypeTemplate, pwaIcons } from './pwa-icons-helper' 5 | 6 | export async function registerPwaIconsTypes( 7 | ctx: NuxtPWAContext, 8 | runtimeDir: string, 9 | ) { 10 | const { options, nuxt, resolver } = ctx 11 | // we need to resolve first the PWA assets options: the resolved options set at pwa-icons::resolvePWAAssetsOptions 12 | const pwaAssets = options.pwaAssets && !options.pwaAssets.disabled 13 | let dts: DtsInfo | undefined 14 | if (pwaAssets) { 15 | try { 16 | const { preparePWAIconTypes } = await import('./pwa-icons') 17 | dts = await preparePWAIconTypes(ctx) 18 | } 19 | catch { 20 | dts = undefined 21 | } 22 | } 23 | 24 | nuxt.options.alias['#pwa'] = resolver.resolve(runtimeDir, 'composables/index') 25 | nuxt.options.build.transpile.push('#pwa') 26 | 27 | addImports([ 28 | 'usePWA', 29 | 'useTransparentPwaIcon', 30 | 'useMaskablePwaIcon', 31 | 'useFaviconPwaIcon', 32 | 'useApplePwaIcon', 33 | 'useAppleSplashScreenPwaIcon', 34 | ].map(key => ({ 35 | name: key, 36 | as: key, 37 | from: resolver.resolve(runtimeDir, 'composables/index'), 38 | }))) 39 | 40 | // TODO: add `{ node: true, nuxt: true }` to addTypeTemplate when migrating to nuxt 4, rn doing it manually 41 | const templates: string[] = [] 42 | const isNuxt4 = ctx.nuxt4 43 | const dtsContent = dts?.dts 44 | if (dtsContent) { 45 | templates.push(addTypeTemplate({ 46 | write: true, 47 | filename: 'pwa-icons/pwa-icons.d.ts', 48 | getContents: () => dtsContent, 49 | }).dst) 50 | } 51 | else { 52 | templates.push(addTypeTemplate({ 53 | write: true, 54 | filename: 'pwa-icons/pwa-icons.d.ts', 55 | getContents: () => pwaIcons(), 56 | }).dst) 57 | } 58 | templates.push(addPwaTypeTemplate('PwaTransparentImage', isNuxt4, dts?.transparent)) 59 | templates.push(addPwaTypeTemplate('PwaMaskableImage', isNuxt4, dts?.maskable)) 60 | templates.push(addPwaTypeTemplate('PwaFaviconImage', isNuxt4, dts?.favicon)) 61 | templates.push(addPwaTypeTemplate('PwaAppleImage', isNuxt4, dts?.apple)) 62 | templates.push(addPwaTypeTemplate('PwaAppleSplashScreenImage', isNuxt4, dts?.appleSplashScreen)) 63 | 64 | // TODO: remove this once migrated 65 | if (ctx.nuxt4) { 66 | ctx.nuxt.hook('prepare:types', (context) => { 67 | const nodeReferences = (context as any).nodeReferences 68 | for (const path of templates) { 69 | nodeReferences.push({ path }) 70 | } 71 | }) 72 | } 73 | 74 | return !!dts 75 | } 76 | -------------------------------------------------------------------------------- /test/build.test.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync } from 'node:fs' 2 | import process from 'node:process' 3 | import { describe, expect, it } from 'vitest' 4 | 5 | const build = process.env.TEST_BUILD === 'true' 6 | const nuxt3_13 = process.env.NUXT_ECOSYSTEM_CI === 'true' 7 | 8 | describe(`test-${build ? 'build' : 'generate'}`, () => { 9 | it('service worker is generated: ', () => { 10 | const swName = build 11 | ? './playground/.output/public/sw.js' 12 | : './playground/dist/sw.js' 13 | const webManifest = build 14 | ? './playground/.output/public/manifest.webmanifest' 15 | : './playground/dist/manifest.webmanifest' 16 | expect(existsSync(swName), `${swName} doesn't exist`).toBeTruthy() 17 | expect(existsSync(webManifest), `${webManifest} doesn't exist`).toBeTruthy() 18 | const swContent = readFileSync(swName, 'utf-8') 19 | let match: RegExpMatchArray | null 20 | // vite5/rollup4 inlining workbox-***.js in the sw.js 21 | if (nuxt3_13) { 22 | match = swContent.match(/define\(\["\.\/(workbox-\w+)"/) 23 | expect(match, `workbox-***.js entry found in ${swName}`).toBeFalsy() 24 | } 25 | else { 26 | match = swContent.match(/define\(\["\.\/(workbox-\w+)"/) 27 | expect(match && match.length === 2, `workbox-***.js entry not found in ${swName}`).toBeTruthy() 28 | const workboxName = `./playground/${build ? '.output/public' : 'dist'}/${match?.[1]}.js` 29 | expect(existsSync(workboxName), `${workboxName} doesn't exist`).toBeTruthy() 30 | } 31 | match = swContent.match(/url:\s*"manifest\.webmanifest"/) 32 | expect(match && match.length === 1, 'missing manifest.webmanifest in sw precache manifest').toBeTruthy() 33 | match = swContent.match(/url:\s*"\/"/) 34 | expect(match && match.length === 1, 'missing entry point route (/) in sw precache manifest').toBeTruthy() 35 | match = swContent.match(/url:\s*"about"/) 36 | expect(match && match.length === 1, 'missing about route (/about) in sw precache manifest').toBeTruthy() 37 | match = swContent.match(/url:\s*"_nuxt\/.*\.(css|js)"/) 38 | expect(match && match.length > 0, 'missing _nuxt/**.(css|js) in sw precache manifest').toBeTruthy() 39 | match = swContent.match(/url:\s*"(.*\/)?_payload.json"/) 40 | expect(match && match.length === 2, 'missing _payload.json and about/_payload.json entries in sw precache manifest').toBeTruthy() 41 | match = swContent.match(/url:\s*"_nuxt\/builds\/.*\.json"/) 42 | expect(match && match.length > 0, 'missing App Manifest json entries in sw precache manifest').toBeTruthy() 43 | if (build) { 44 | match = swContent.match(/url:\s*"server\//) 45 | expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy() 46 | } 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /playground/public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground-assets/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | 11 | 21 | 22 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process' 2 | import { defineConfig, devices } from '@playwright/test' 3 | 4 | const url = 'http://localhost:4173' 5 | 6 | const build = process.env.TEST_BUILD === 'true' 7 | 8 | /** 9 | * Read environment variables from file. 10 | * https://github.com/motdotla/dotenv 11 | */ 12 | // require('dotenv').config(); 13 | 14 | /** 15 | * See https://playwright.dev/docs/test-configuration. 16 | */ 17 | export default defineConfig({ 18 | testDir: './client-test', 19 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 20 | outputDir: 'test-results/', 21 | timeout: 5 * 1000, 22 | expect: { 23 | /** 24 | * Maximum time expect() should wait for the condition to be met. 25 | * For example in `await expect(locator).toHaveText();` 26 | */ 27 | timeout: 1000, 28 | }, 29 | /* Run tests in files in parallel */ 30 | fullyParallel: true, 31 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 32 | forbidOnly: !!process.env.CI, 33 | /* Retry on CI only */ 34 | retries: 0, 35 | /* Opt out of parallel tests on CI. */ 36 | workers: process.env.CI ? 1 : undefined, 37 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 38 | reporter: 'line', 39 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 40 | use: { 41 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 42 | actionTimeout: 0, 43 | /* Base URL to use in actions like `await page.goto('/')`. */ 44 | baseURL: url, 45 | 46 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 47 | trace: 'on-first-retry', 48 | }, 49 | 50 | /* Configure projects for major browsers */ 51 | projects: [ 52 | { 53 | name: 'chromium', 54 | use: { ...devices['Desktop Chrome'] }, 55 | }, 56 | 57 | // { 58 | // name: 'firefox', 59 | // use: { ...devices['Desktop Firefox'] }, 60 | // }, 61 | 62 | // { 63 | // name: 'webkit', 64 | // use: { ...devices['Desktop Safari'] }, 65 | // }, 66 | 67 | /* Test against mobile viewports. */ 68 | // { 69 | // name: 'Mobile Chrome', 70 | // use: { ...devices['Pixel 5'] }, 71 | // }, 72 | // { 73 | // name: 'Mobile Safari', 74 | // use: { ...devices['iPhone 12'] }, 75 | // }, 76 | 77 | /* Test against branded browsers. */ 78 | // { 79 | // name: 'Microsoft Edge', 80 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 81 | // }, 82 | // { 83 | // name: 'Google Chrome', 84 | // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, 85 | // }, 86 | ], 87 | 88 | /* Run your local dev server before starting the tests */ 89 | webServer: { 90 | command: `pnpm run test:${build ? 'build' : 'generate'}:serve`, 91 | url, 92 | reuseExistingServer: !process.env.CI, 93 | }, 94 | }) 95 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { HookResult } from '@nuxt/schema' 2 | import type { ResolvedVitePWAOptions, VitePWAOptions } from 'vite-plugin-pwa' 3 | 4 | export interface ClientOptions { 5 | /** 6 | * Exposes the plugin: defaults to true. 7 | */ 8 | registerPlugin?: boolean 9 | /** 10 | * Registers a periodic sync for updates interval: value in seconds. 11 | */ 12 | periodicSyncForUpdates?: number 13 | /** 14 | * Will prevent showing native PWA install prompt: defaults to false. 15 | * 16 | * When set to true or no empty string, the native PWA install prompt will be prevented. 17 | * 18 | * When set to a string, it will be used as the key in `localStorage` to prevent show the PWA install prompt widget. 19 | * 20 | * When set to true, the key used will be `vite-pwa:hide-install`. 21 | */ 22 | installPrompt?: boolean | string 23 | } 24 | 25 | export interface PwaModuleOptions extends Partial { 26 | /** 27 | * Experimental features. 28 | */ 29 | experimental?: { 30 | /** 31 | * NOTE: this option will be ignored if using the `injectManifest` strategy or when Nuxt experimental payload 32 | * extraction is disabled. 33 | * 34 | * Enable custom runtime caching to resolve the payload.json requests with query parameters when offline: 35 | * - Workbox doesn't allow to configure `precacheAndRoute` `urlManipulation` option when using the `generateSW` strategy. 36 | * - Nuxt SSG will generate a payload.json file and will fetch it with a query parameter. 37 | * - The service worker cannot resolve the payload.json request with query parameters, and you won't get the payload when offline. 38 | * 39 | * Enabling this option will add a custom runtime caching handler to the service worker to resolve the payload files 40 | * with query parameters when offline: the runtime caching handler will redirect to the payload.json file without 41 | * query parameters when the original request fails. 42 | * 43 | * If you're using `injectManifest` strategy, you can fix the issue in your custom service worker adding the 44 | * following `urlManipulation` callback to the `precacheAndRouter` call: 45 | * ```ts 46 | * // self.__WB_MANIFEST is the default injection point 47 | * precacheAndRoute( 48 | * self.__WB_MANIFEST, 49 | * { 50 | * urlManipulation: ({ url }) => { 51 | * const urls: URL[] = [] 52 | * if (url.pathname.endsWith('_payload.json')) { 53 | * const newUrl = new URL(url.href) 54 | * newUrl.search = '' 55 | * urls.push(newUrl) 56 | * } 57 | * return urls 58 | * } 59 | * } 60 | * ) 61 | * ``` 62 | */ 63 | enableWorkboxPayloadQueryParams?: true 64 | } 65 | registerWebManifestInRouteRules?: boolean 66 | /** 67 | * Writes the plugin to disk: defaults to false (debug). 68 | */ 69 | writePlugin?: boolean 70 | /** 71 | * Options for plugin. 72 | */ 73 | client?: ClientOptions 74 | } 75 | 76 | export interface PwaModuleHooks { 77 | 'pwa:beforeBuildServiceWorker': (options: ResolvedVitePWAOptions) => HookResult 78 | } 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vite-pwa/nuxt", 3 | "type": "module", 4 | "version": "1.1.0", 5 | "packageManager": "pnpm@10.18.2", 6 | "description": "Zero-config PWA for Nuxt 3", 7 | "author": "antfu ", 8 | "license": "MIT", 9 | "funding": "https://github.com/sponsors/antfu", 10 | "homepage": "https://github.com/vite-pwa/nuxt#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/vite-pwa/nuxt.git" 14 | }, 15 | "bugs": "https://github.com/vite-pwa/nuxt/issues", 16 | "keywords": [ 17 | "nuxt", 18 | "pwa", 19 | "workbox", 20 | "vite-plugin-pwa", 21 | "nuxt-module" 22 | ], 23 | "exports": { 24 | ".": { 25 | "types": "./dist/types.d.mts", 26 | "default": "./dist/module.mjs" 27 | }, 28 | "./configuration": { 29 | "types": "./configuration.d.ts" 30 | }, 31 | "./package.json": "./package.json", 32 | "./*": "./*" 33 | }, 34 | "main": "./dist/module.mjs", 35 | "types": "./dist/types.d.ts", 36 | "files": [ 37 | "*.d.ts", 38 | "dist" 39 | ], 40 | "scripts": { 41 | "prepack": "nuxt-module-build prepare && nuxt-module-build build", 42 | "dev": "nuxi dev playground", 43 | "dev:generate": "nuxi generate playground", 44 | "dev:generate:netlify": "NITRO_PRESET=netlify nuxi generate playground", 45 | "dev:generate:vercel": "NITRO_PRESET=vercel nuxi generate playground", 46 | "dev:build": "nuxi build playground", 47 | "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground", 48 | "dev:preview:build": "nr dev:build && node playground/.output/server/index.mjs", 49 | "dev:preview:generate": "nr dev:generate && serve playground/dist", 50 | "dev:assets:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground-assets", 51 | "dev:assets": "nr dev:assets:prepare && nuxi dev playground-assets", 52 | "dev:assets:generate": "nr dev:assets:prepare && nuxi generate playground-assets", 53 | "dev:assets:build": "nr dev:assets:prepare && nuxi build playground-assets", 54 | "release": "bumpp", 55 | "lint": "eslint .", 56 | "lint:fix": "nr lint --fix", 57 | "test:build:serve": "PORT=4173 node playground/.output/server/index.mjs", 58 | "test:generate:serve": "PORT=4173 serve playground/dist", 59 | "test:build": "nr dev:build && NUXT_ECOSYSTEM_CI=true TEST_BUILD=true vitest run && TEST_BUILD=true playwright test", 60 | "test:generate": "nr dev:generate && NUXT_ECOSYSTEM_CI=true vitest run && playwright test", 61 | "test:build:local": "nr dev:build && TEST_BUILD=true vitest run && TEST_BUILD=true playwright test", 62 | "test:generate:local": "nr dev:generate && vitest run && playwright test", 63 | "test:local": "nr test:build:local && nr test:generate:local", 64 | "test": "nr test:build && nr test:generate", 65 | "test:with-build": "nr dev:prepare && nr prepack && nr test" 66 | }, 67 | "peerDependencies": { 68 | "@vite-pwa/assets-generator": "^1.0.0" 69 | }, 70 | "peerDependenciesMeta": { 71 | "@vite-pwa/assets-generator": { 72 | "optional": true 73 | } 74 | }, 75 | "dependencies": { 76 | "@nuxt/kit": "^3.9.0", 77 | "pathe": "^1.1.1", 78 | "ufo": "^1.3.2", 79 | "vite-plugin-pwa": "^1.2.0" 80 | }, 81 | "devDependencies": { 82 | "@antfu/eslint-config": "^4.11.0", 83 | "@antfu/ni": "^0.21.10", 84 | "@nuxt/module-builder": "^0.8.3", 85 | "@nuxt/schema": "^3.10.1", 86 | "@nuxt/test-utils": "^3.11.0", 87 | "@playwright/test": "^1.40.1", 88 | "@types/node": "^18", 89 | "bumpp": "^9.2.0", 90 | "eslint": "^9.23.0", 91 | "node-fetch-native": "^1.4.1", 92 | "nuxt": "^3.10.1", 93 | "publint": "^0.2.5", 94 | "rimraf": "^5.0.5", 95 | "serve": "^14.2.1", 96 | "typescript": "^5.4.5", 97 | "vitest": "^1.1.0", 98 | "vue-tsc": "^1.8.27" 99 | }, 100 | "resolutions": { 101 | "@nuxt/kit": "^3.10.1" 102 | }, 103 | "build": { 104 | "externals": [ 105 | "node:child_process", 106 | "node:fs", 107 | "consola", 108 | "esbuild", 109 | "h3", 110 | "pathe", 111 | "rollup", 112 | "ufo", 113 | "vite", 114 | "vite-plugin-pwa" 115 | ] 116 | }, 117 | "stackblitz": { 118 | "startCommand": "nr prepack && nr dev:prepare && nr dev" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/runtime/composables/index.ts: -------------------------------------------------------------------------------- 1 | import type { PwaAppleImageProps } from '#build/pwa-icons/PwaAppleImage' 2 | import type { PwaAppleSplashScreenImageProps } from '#build/pwa-icons/PwaAppleSplashScreenImage' 3 | import type { PwaFaviconImageProps } from '#build/pwa-icons/PwaFaviconImage' 4 | import type { PwaMaskableImageProps } from '#build/pwa-icons/PwaMaskableImage' 5 | import type { PwaTransparentImageProps } from '#build/pwa-icons/PwaTransparentImage' 6 | import type { MaybeRef, UnwrapNestedRefs } from 'vue' 7 | import type { PwaInjection } from '../plugins/types' 8 | import { useNuxtApp } from '#imports' 9 | import { computed, toValue } from 'vue' 10 | 11 | export interface PWAImage { 12 | image: string 13 | alt?: string 14 | width?: number 15 | height?: number 16 | crossorigin?: '' | 'anonymous' | 'use-credentials' 17 | loading?: 'lazy' | 'eager' 18 | decoding?: 'async' | 'auto' | 'sync' 19 | nonce?: string 20 | [key: string]: any 21 | } 22 | 23 | export interface PWAIcon { 24 | src: string 25 | key: any 26 | alt?: string 27 | width?: number 28 | height?: number 29 | crossorigin?: '' | 'anonymous' | 'use-credentials' 30 | loading?: 'lazy' | 'eager' 31 | decoding?: 'async' | 'auto' | 'sync' 32 | nonce?: string 33 | [key: string]: any 34 | } 35 | 36 | export type PWAImageType = T extends 'transparent' 37 | ? PwaTransparentImageProps['image'] | (Omit & { image: PwaTransparentImageProps['image'] }) 38 | : T extends 'maskable' 39 | ? PwaMaskableImageProps['image'] | Omit & { image: PwaMaskableImageProps['image'] } 40 | : T extends 'favicon' 41 | ? PwaFaviconImageProps['image'] | Omit & { image: PwaFaviconImageProps['image'] } 42 | : T extends 'apple' 43 | ? PwaAppleImageProps['image'] | Omit & { image: PwaAppleImageProps['image'] } 44 | : T extends 'appleSplashScreen' 45 | ? PwaAppleSplashScreenImageProps['image'] | Omit & { image: PwaAppleSplashScreenImageProps['image'] } 46 | : never 47 | 48 | export type TransparentImageType = MaybeRef> 49 | export type MaskableImageType = MaybeRef> 50 | export type FaviconImageType = MaybeRef> 51 | export type AppleImageType = MaybeRef> 52 | export type AppleSplashScreenImageType = MaybeRef> 53 | 54 | export function useTransparentPwaIcon(image: TransparentImageType) { 55 | return usePWAIcon('transparent', image) 56 | } 57 | export function useMaskablePwaIcon(image: MaskableImageType) { 58 | return usePWAIcon('maskable', image) 59 | } 60 | export function useFaviconPwaIcon(image: FaviconImageType) { 61 | return usePWAIcon('favicon', image) 62 | } 63 | export function useApplePwaIcon(image: AppleImageType) { 64 | return usePWAIcon('apple', image) 65 | } 66 | export function useAppleSplashScreenPwaIcon(image: AppleSplashScreenImageType) { 67 | return usePWAIcon('appleSplashScreen', image) 68 | } 69 | export function usePWA(): UnwrapNestedRefs | undefined { 70 | return useNuxtApp().$pwa 71 | } 72 | 73 | function usePWAIcon( 74 | type: 'transparent' | 'maskable' | 'favicon' | 'apple' | 'appleSplashScreen', 75 | pwaImage: MaybeRef, 76 | ) { 77 | const pwaIcons = useNuxtApp().$pwaIcons 78 | const icon = computed(() => { 79 | const pwaIcon = toValue(pwaImage) 80 | const iconName = typeof pwaIcon === 'object' ? pwaIcon.image : pwaIcon 81 | const image = pwaIcons?.[type]?.[iconName]?.asImage 82 | if (!image) 83 | return 84 | 85 | if (typeof pwaIcon === 'string') { 86 | return { 87 | width: image.width, 88 | height: image.height, 89 | key: image.key, 90 | src: image.src, 91 | } 92 | } 93 | 94 | const { 95 | alt, 96 | width, 97 | height, 98 | crossorigin, 99 | loading, 100 | decoding, 101 | nonce, 102 | image: _image, 103 | ...rest 104 | } = pwaIcon 105 | 106 | return { 107 | alt, 108 | width: width ?? image.width, 109 | height: height ?? image.height, 110 | crossorigin, 111 | loading, 112 | decoding, 113 | nonce, 114 | ...rest, 115 | key: image.key, 116 | src: image.src, 117 | } 118 | }) 119 | 120 | return { icon } 121 | } 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | @vite-pwa/nuxt - Zero-config PWA for Nuxt 3
3 | Zero-config PWA Plugin for Nuxt 3 4 |

5 | 6 |

7 | 8 | NPM version 9 | 10 | 11 | NPM Downloads 12 | 13 | 14 | Docs & Guides 15 | 16 |
17 | 18 | GitHub stars 19 | 20 |

21 | 22 |
23 | 24 |

25 | 26 | 27 | 28 |

29 | 30 | ## 🚀 Features 31 | 32 | - 📖 [**Documentation & guides**](https://vite-pwa-org.netlify.app/) 33 | - 👌 **Zero-Config**: sensible built-in default configs for common use cases 34 | - 🔩 **Extensible**: expose the full ability to customize the behavior of the plugin 35 | - 🦾 **Type Strong**: written in [TypeScript](https://www.typescriptlang.org/) 36 | - 🔌 **Offline Support**: generate service worker with offline support (via Workbox) 37 | - ⚡ **Fully tree shakable**: auto inject Web App Manifest 38 | - 💬 **Prompt for new content**: built-in support for Vanilla JavaScript, Vue 3, React, Svelte, SolidJS and Preact 39 | - ⚙️ **Stale-while-revalidate**: automatic reload when new content is available 40 | - ✨ **Static assets handling**: configure static assets for offline support 41 | - 🐞 **Development Support**: debug your custom service worker logic as you develop your application 42 | - 🛠️ **Versatile**: integration with meta frameworks: [îles](https://github.com/ElMassimo/iles), [SvelteKit](https://github.com/sveltejs/kit), [VitePress](https://github.com/vuejs/vitepress), [Astro](https://github.com/withastro/astro), [Nuxt 3](https://github.com/nuxt/nuxt) and [Remix](https://github.com/remix-run/remix) 43 | - 💥 **PWA Assets Generator**: generate all the PWA assets from a single command and a single source image 44 | - 🚀 **PWA Assets Integration**: serving, generating and injecting PWA Assets on the fly in your application 45 | 46 | ## 📦 Install 47 | 48 | > From v0.4.0, `@vite-pwa/nuxt` requires Vite 5 and Nuxt 3.9.0+. 49 | 50 | > For older versions, `@vite-pwa/nuxt` requires Vite 3.2.0+ and Nuxt 3.0.0+. 51 | 52 | ```bash 53 | npx nuxi@latest module add @vite-pwa/nuxt 54 | ``` 55 | 56 | ## 🦄 Usage 57 | 58 | Add `@vite-pwa/nuxt` module to `nuxt.config.ts` and configure it: 59 | 60 | ```ts 61 | // nuxt.config.ts 62 | import { defineNuxtConfig } from 'nuxt/config' 63 | 64 | export default defineNuxtConfig({ 65 | modules: [ 66 | '@vite-pwa/nuxt' 67 | ], 68 | pwa: { 69 | /* PWA options */ 70 | } 71 | }) 72 | ``` 73 | 74 | Read the [📖 documentation](https://vite-pwa-org.netlify.app/frameworks/nuxt) for a complete guide on how to configure and use 75 | this plugin. 76 | 77 | ## ⚡️ Examples 78 | 79 | You need to stop the dev server once started and then to see the PWA in action run: 80 | - `nr dev:preview:build`: Nuxt build command + start server 81 | - `nr dev:preview:generate`: Nuxt generate command + start server 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 100 | 101 | 102 |
ExampleSourcePlayground
Auto Update PWAGitHub 96 | 97 | Open in StackBlitz 98 | 99 |
103 | 104 | ## 👀 Full config 105 | 106 | Check out the type declaration [src/types.ts](./src/types.ts) and the following links for more details. 107 | 108 | - [Web app manifests](https://developer.mozilla.org/en-US/docs/Web/Manifest) 109 | - [Workbox](https://developers.google.com/web/tools/workbox) 110 | 111 | ## 📄 License 112 | 113 | [MIT](./LICENSE) License © 2023-PRESENT [Anthony Fu](https://github.com/antfu) 114 | -------------------------------------------------------------------------------- /src/runtime/plugins/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue' 2 | import type { UnwrapNestedRefs } from 'vue' 3 | 4 | /** 5 | * Result of the native PWA installation prompt. 6 | */ 7 | export interface UserChoice { 8 | /** 9 | * The user's selection on the install prompt. 10 | */ 11 | outcome: 'accepted' | 'dismissed' 12 | /** 13 | * Platform identifier provided by the browser for where the prompt was shown 14 | * (for example, 'web' or a store-specific value). The exact values are 15 | * browser-dependent and not standardized. 16 | */ 17 | platform: string 18 | } 19 | 20 | /** 21 | * Browser event fired before showing the native PWA install prompt. 22 | * 23 | * This event allows delaying the native prompt and showing a custom UI first. 24 | */ 25 | export type BeforeInstallPromptEvent = Event & { 26 | /** 27 | * Triggers the browser's native install prompt. 28 | */ 29 | prompt: () => void 30 | /** 31 | * Resolves with the user's selection once they interact with the prompt. 32 | */ 33 | userChoice: Promise 34 | } 35 | 36 | /** 37 | * Reactive PWA state and helpers injected into Nuxt as `$pwa`. 38 | */ 39 | export interface PwaInjection { 40 | /** 41 | * @deprecated Use `isPWAInstalled` instead. Legacy boolean computed from 42 | * browser heuristics to detect if the app is installed. 43 | */ 44 | isInstalled: boolean 45 | /** 46 | * Whether the app is currently installed as a PWA. 47 | * This value updates as installation state changes (e.g. display-mode changes). 48 | */ 49 | isPWAInstalled: Ref 50 | /** 51 | * When `true`, your UI should show a custom install prompt. This flag is set 52 | * after the `beforeinstallprompt` event is captured and cleared when the user 53 | * proceeds or cancels. 54 | */ 55 | showInstallPrompt: Ref 56 | /** 57 | * Cancels the custom install flow and hides future prompts by setting an 58 | * opt-out flag (stored in `localStorage` when configured via `installPrompt`). 59 | */ 60 | cancelInstall: () => void 61 | /** 62 | * Shows the native install prompt if available and returns the user's choice. 63 | * Returns `undefined` when the prompt is not available or not currently shown. 64 | */ 65 | install: () => Promise 66 | /** 67 | * Whether the service worker has reached the `activated` state. 68 | */ 69 | swActivated: Ref 70 | /** 71 | * Indicates that registering the service worker failed. 72 | */ 73 | registrationError: Ref 74 | /** 75 | * Becomes `true` when the app is ready to work offline (precache completed). 76 | * This flag is activated only once, when the service worker is registered and activated for first time. 77 | */ 78 | offlineReady: Ref 79 | /** 80 | * Becomes `true` when a new service worker is waiting to activate and a 81 | * refresh is recommended to apply updates. 82 | */ 83 | needRefresh: Ref 84 | /** 85 | * Applies the waiting service worker. When `reloadPage` is `true`, reloads 86 | * the page after activating the new service worker. 87 | */ 88 | updateServiceWorker: (reloadPage?: boolean | undefined) => Promise 89 | /** 90 | * Dismisses update/install notifications by resetting `offlineReady` and 91 | * `needRefresh` to `false`. 92 | */ 93 | cancelPrompt: () => Promise 94 | /** 95 | * From version 0.10.8 it is deprecated, use a plugin instead with the new Nuxt Runtime Client Hooks: 96 | * ```ts 97 | * // plugins/pwa.client.ts 98 | * export default defineNuxtPlugin((nuxtApp) => { 99 | * nuxtApp.hook('service-worker:registered', ({ url, registration }) => { 100 | * // eslint-disable-next-line no-console 101 | * console.log(`service worker registered at ${url}`, registration) 102 | * }) 103 | * nuxtApp.hook('service-worker:registration-failed', ({ error }) => { 104 | * console.error(`service worker registration failed`, error) 105 | * }) 106 | * nuxtApp.hook('service-worker:activated', ({ url, registration }) => { 107 | * // eslint-disable-next-line no-console 108 | * console.log(`service worker activated at ${url}`, registration) 109 | * }) 110 | * }) 111 | * ``` 112 | * 113 | * @deprecated Directly access the registration in your own plugin via the 114 | * runtime client hooks instead. 115 | */ 116 | getSWRegistration: () => ServiceWorkerRegistration | undefined 117 | } 118 | 119 | declare module '#app' { 120 | interface NuxtApp { 121 | /** 122 | * Reactive PWA state and controls provided by @vite-pwa/nuxt. 123 | * 124 | * Example: 125 | * ```ts 126 | * const { $pwa } = useNuxtApp() 127 | * if ($pwa?.needRefresh) await $pwa.updateServiceWorker(true) 128 | * ``` 129 | */ 130 | $pwa?: UnwrapNestedRefs 131 | } 132 | } 133 | 134 | declare module 'vue' { 135 | interface ComponentCustomProperties { 136 | /** 137 | * Reactive PWA state and controls provided by @vite-pwa/nuxt. 138 | * 139 | * Example: 140 | * ```ts 141 | * const { $pwa } = useNuxtApp() 142 | * if ($pwa?.needRefresh) await $pwa.updateServiceWorker(true) 143 | * ``` 144 | */ 145 | $pwa?: UnwrapNestedRefs 146 | } 147 | } 148 | 149 | export {} 150 | -------------------------------------------------------------------------------- /src/runtime/plugins/pwa.client.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from '#app' 2 | import type { UnwrapNestedRefs } from 'vue' 3 | import type { BeforeInstallPromptEvent, PwaInjection, UserChoice } from './types' 4 | import { defineNuxtPlugin } from '#imports' 5 | import { display, installPrompt, periodicSyncForUpdates } from 'virtual:nuxt-pwa-configuration' 6 | import { useRegisterSW } from 'virtual:pwa-register/vue' 7 | import { nextTick, reactive, ref } from 'vue' 8 | 9 | const plugin: Plugin<{ 10 | pwa?: UnwrapNestedRefs 11 | }> = defineNuxtPlugin({ 12 | name: 'vite-pwa:nuxt:client:plugin', 13 | enforce: 'post', 14 | parallel: true, 15 | setup(nuxtApp) { 16 | const registrationError = ref(false) 17 | const swActivated = ref(false) 18 | const showInstallPrompt = ref(false) 19 | const hideInstall = ref(!installPrompt ? true : localStorage.getItem(installPrompt) === 'true') 20 | 21 | // https://thomashunter.name/posts/2021-12-11-detecting-if-pwa-twa-is-installed 22 | const ua = navigator.userAgent 23 | const ios = ua.match(/iPhone|iPad|iPod/) 24 | const useDisplay = display === 'standalone' || display === 'minimal-ui' ? `${display}` : 'standalone' 25 | const standalone = window.matchMedia(`(display-mode: ${useDisplay})`).matches 26 | const isInstalled = ref(!!(standalone || (ios && !ua.match(/Safari/)))) 27 | const isPWAInstalled = ref(isInstalled.value) 28 | 29 | window.matchMedia(`(display-mode: ${useDisplay})`).addEventListener('change', (e) => { 30 | // PWA on fullscreen mode will not match standalone nor minimal-ui 31 | if (!isPWAInstalled.value && e.matches) 32 | isPWAInstalled.value = true 33 | }) 34 | 35 | let swRegistration: ServiceWorkerRegistration | undefined 36 | 37 | const getSWRegistration = () => swRegistration 38 | 39 | const registerPeriodicSync = (swUrl: string, r: ServiceWorkerRegistration, timeout: number) => { 40 | setInterval(async () => { 41 | // prevent fetch when installing new service worker 42 | if ((r && r.installing) || (('connection' in navigator) && !navigator.onLine)) 43 | return 44 | 45 | const resp = await fetch(swUrl, { 46 | cache: 'no-store', 47 | headers: { 48 | 'cache': 'no-store', 49 | 'cache-control': 'no-cache', 50 | }, 51 | }) 52 | 53 | if (resp?.status === 200) 54 | await r.update() 55 | }, timeout) 56 | } 57 | 58 | const { 59 | offlineReady, 60 | needRefresh, 61 | updateServiceWorker, 62 | } = useRegisterSW({ 63 | immediate: true, 64 | onRegisterError(error) { 65 | nuxtApp.hooks.callHook('service-worker:registration-failed', { error: error as unknown }) 66 | registrationError.value = true 67 | }, 68 | onRegisteredSW(swUrl, r) { 69 | swRegistration = r 70 | const timeout = periodicSyncForUpdates 71 | nuxtApp.hooks.callHook('service-worker:registered', { url: swUrl, registration: r }) 72 | // should add support in pwa plugin 73 | if (r?.active?.state === 'activated') { 74 | swActivated.value = true 75 | if (timeout > 0) { 76 | registerPeriodicSync(swUrl, r, timeout * 1000) 77 | } 78 | nuxtApp.hooks.callHook('service-worker:activated', { url: swUrl, registration: r }) 79 | } 80 | else if (r?.installing) { 81 | r.installing.addEventListener('statechange', (e) => { 82 | const sw = e.target as ServiceWorker 83 | swActivated.value = sw.state === 'activated' 84 | if (swActivated.value) { 85 | if (timeout > 0) { 86 | registerPeriodicSync(swUrl, r, timeout * 1000) 87 | } 88 | nuxtApp.hooks.callHook('service-worker:activated', { url: swUrl, registration: r }) 89 | } 90 | }) 91 | } 92 | }, 93 | }) 94 | 95 | const cancelPrompt = async () => { 96 | offlineReady.value = false 97 | needRefresh.value = false 98 | } 99 | 100 | let install: () => Promise = () => Promise.resolve(undefined) 101 | let cancelInstall: () => void = () => { 102 | } 103 | 104 | if (!hideInstall.value) { 105 | let deferredPrompt: BeforeInstallPromptEvent | undefined 106 | 107 | const beforeInstallPrompt = (e: Event) => { 108 | e.preventDefault() 109 | deferredPrompt = e as BeforeInstallPromptEvent 110 | showInstallPrompt.value = true 111 | } 112 | window.addEventListener('beforeinstallprompt', beforeInstallPrompt) 113 | window.addEventListener('appinstalled', () => { 114 | deferredPrompt = undefined 115 | showInstallPrompt.value = false 116 | }) 117 | 118 | cancelInstall = () => { 119 | deferredPrompt = undefined 120 | showInstallPrompt.value = false 121 | window.removeEventListener('beforeinstallprompt', beforeInstallPrompt) 122 | hideInstall.value = true 123 | localStorage.setItem(installPrompt!, 'true') 124 | } 125 | 126 | install = async () => { 127 | if (!showInstallPrompt.value || !deferredPrompt) { 128 | showInstallPrompt.value = false 129 | return undefined 130 | } 131 | 132 | showInstallPrompt.value = false 133 | await nextTick() 134 | deferredPrompt.prompt() 135 | return await deferredPrompt.userChoice 136 | } 137 | } 138 | 139 | return { 140 | provide: { 141 | pwa: reactive({ 142 | isInstalled, 143 | isPWAInstalled, 144 | showInstallPrompt, 145 | cancelInstall, 146 | install, 147 | swActivated, 148 | registrationError, 149 | offlineReady, 150 | needRefresh, 151 | updateServiceWorker, 152 | cancelPrompt, 153 | getSWRegistration, 154 | }) satisfies UnwrapNestedRefs, 155 | }, 156 | } 157 | }, 158 | }) 159 | 160 | export default plugin 161 | -------------------------------------------------------------------------------- /src/utils/pwa-icons-helper.ts: -------------------------------------------------------------------------------- 1 | import { addPluginTemplate, addTypeTemplate } from '@nuxt/kit' 2 | 3 | export interface DtsInfo { 4 | dts?: string 5 | transparent?: string 6 | maskable?: string 7 | favicon?: string 8 | apple?: string 9 | appleSplashScreen?: string 10 | } 11 | 12 | export interface PwaIconsTypes { 13 | transparent?: string[] 14 | maskable?: string[] 15 | favicon?: string[] 16 | apple?: string[] 17 | appleSplashScreen?: string[] 18 | } 19 | 20 | export function addPwaTypeTemplate( 21 | filename: string, 22 | isNuxt4: boolean, 23 | content?: string, 24 | ) { 25 | if (content?.length) { 26 | return addTypeTemplate({ 27 | write: true, 28 | filename: `pwa-icons/${filename}.d.ts`, 29 | getContents: () => content, 30 | }).dst 31 | } 32 | else { 33 | return addTypeTemplate({ 34 | write: true, 35 | getContents: () => generatePwaImageType(filename, isNuxt4), 36 | filename: `pwa-icons/${filename}.d.ts`, 37 | }).dst 38 | } 39 | } 40 | 41 | export function pwaIcons(types?: PwaIconsTypes) { 42 | return `// Generated by @vite-pwa/nuxt 43 | import type { AppleSplashScreenLink, FaviconLink, HtmlLink, IconAsset } from '@vite-pwa/assets-generator/api' 44 | 45 | export interface PWAAssetIconImage { 46 | width?: number 47 | height?: number 48 | key: string 49 | src: string 50 | } 51 | export type PWAAssetIcon = Omit, 'buffer'> & { 52 | asImage: PWAAssetIconImage 53 | } 54 | export interface PWAIcons { 55 | transparent: Record<${generateTypes(types?.transparent)}, PWAAssetIcon> 56 | maskable: Record<${generateTypes(types?.maskable)}, PWAAssetIcon> 57 | favicon: Record<${generateTypes(types?.favicon)}, PWAAssetIcon> 58 | apple: Record<${generateTypes(types?.apple)}, PWAAssetIcon> 59 | appleSplashScreen: Record<${generateTypes(types?.appleSplashScreen)}, PWAAssetIcon> 60 | } 61 | 62 | declare module '#app' { 63 | interface NuxtApp { 64 | $pwaIcons?: PWAIcons 65 | } 66 | } 67 | 68 | declare module 'vue' { 69 | interface ComponentCustomProperties { 70 | $pwaIcons?: PWAIcons 71 | } 72 | } 73 | 74 | export {} 75 | ` 76 | } 77 | 78 | export function generatePwaImageType(filename: string, isNuxt4: boolean, names?: string[]) { 79 | const propsName = `${filename}Props` 80 | if (isNuxt4) { 81 | return `// Generated by @vite-pwa/nuxt 82 | export interface ${propsName} { 83 | image: ${generateTypes(names)} 84 | alt?: string 85 | width?: number 86 | height?: number 87 | crossorigin?: '' | 'anonymous' | 'use-credentials' 88 | loading?: 'lazy' | 'eager' 89 | decoding?: 'async' | 'auto' | 'sync' 90 | nonce?: string 91 | } 92 | ` 93 | } 94 | 95 | return `// Generated by @vite-pwa/nuxt 96 | export interface ${propsName} { 97 | image: ${generateTypes(names)} 98 | alt?: string 99 | width?: number 100 | height?: number 101 | crossorigin?: '' | 'anonymous' | 'use-credentials' 102 | loading?: 'lazy' | 'eager' 103 | decoding?: 'async' | 'auto' | 'sync' 104 | nonce?: string 105 | } 106 | type __VLS_NonUndefinedable = T extends undefined ? never : T 107 | type __VLS_TypePropsToRuntimeProps = { 108 | [K in keyof T]-?: {} extends Pick ? { 109 | type: import('vue').PropType<__VLS_NonUndefinedable> 110 | } : { 111 | type: import('vue').PropType 112 | required: true 113 | } 114 | } 115 | declare const _default: import('vue').DefineComponent<__VLS_TypePropsToRuntimeProps<${propsName}>, {}, unknown, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly>>, {}, {}> 116 | export default _default 117 | ` 118 | } 119 | 120 | function generateTypes(types?: string[]) { 121 | return types?.length ? types.map(name => `'${name}'`).join(' | ') : 'string' 122 | } 123 | 124 | export function addPWAIconsPluginTemplate(pwaAssetsEnabled: boolean) { 125 | if (pwaAssetsEnabled) { 126 | addPluginTemplate({ 127 | filename: 'pwa-icons-plugin.ts', 128 | name: 'vite-pwa:nuxt:pwa-icons-plugin', 129 | write: true, 130 | getContents: () => `// Generated by @vite-pwa/nuxt 131 | import { defineNuxtPlugin } from '#imports' 132 | import { pwaAssetsIcons } from 'virtual:pwa-assets/icons' 133 | import type { PWAAssetIcon, PWAIcons } from '#build/pwa-icons/pwa-icons' 134 | 135 | export default defineNuxtPlugin(() => { 136 | return { 137 | provide: { 138 | pwaIcons: { 139 | transparent: configureEntry('transparent'), 140 | maskable: configureEntry('maskable'), 141 | favicon: configureEntry('favicon'), 142 | apple: configureEntry('apple'), 143 | appleSplashScreen: configureEntry('appleSplashScreen') 144 | } satisfies PWAIcons 145 | } 146 | } 147 | }) 148 | 149 | function configureEntry(key: K) { 150 | return Object.values(pwaAssetsIcons[key] ?? {}).reduce((acc, icon) => { 151 | const entry: PWAAssetIcon = { 152 | ...icon, 153 | asImage: { 154 | src: icon.url, 155 | key: \`\${key}-\${icon.name}\` 156 | } 157 | } 158 | if (icon.width && icon.height) { 159 | entry.asImage.width = icon.width 160 | entry.asImage.height = icon.height 161 | } 162 | ;(acc as unknown as any)[icon.name] = entry 163 | return acc 164 | }, {} as PWAIcons[typeof key]) 165 | } 166 | `, 167 | }) 168 | } 169 | else { 170 | addPluginTemplate({ 171 | filename: 'pwa-icons-plugin.ts', 172 | name: 'vite-pwa:nuxt:pwa-icons-plugin', 173 | write: true, 174 | getContents: () => `// Generated by @vite-pwa/nuxt 175 | import { defineNuxtPlugin } from '#imports' 176 | import type { PWAIcons } from '#build/pwa-icons/pwa-icons' 177 | 178 | export default defineNuxtPlugin(() => { 179 | return { 180 | provide: { 181 | pwaIcons: { 182 | transparent: {}, 183 | maskable: {}, 184 | favicon: {}, 185 | apple: {}, 186 | appleSplashScreen: {} 187 | } satisfies PWAIcons 188 | } 189 | } 190 | }) 191 | `, 192 | }) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/utils/pwa-icons.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from '@vite-pwa/assets-generator/config' 2 | import type { ResolvedPWAAssetsOptions } from 'vite-plugin-pwa' 3 | import type { NuxtPWAContext } from '../context' 4 | import type { DtsInfo } from './pwa-icons-helper' 5 | import fs from 'node:fs' 6 | import { access, readFile } from 'node:fs/promises' 7 | import process from 'node:process' 8 | import { instructions } from '@vite-pwa/assets-generator/api/instructions' 9 | import { loadConfig } from '@vite-pwa/assets-generator/config' 10 | import { basename, relative, resolve } from 'pathe' 11 | import { generatePwaImageType, pwaIcons } from './pwa-icons-helper' 12 | 13 | export async function preparePWAIconTypes( 14 | ctx: NuxtPWAContext, 15 | ) { 16 | const { options, nuxt } = ctx 17 | if (!options.pwaAssets || options.pwaAssets.disabled) 18 | return 19 | 20 | const configuration = await resolvePWAAssetsOptions(ctx) 21 | if (!configuration || configuration.disabled) 22 | return 23 | 24 | // use the same logic vite-plugin-pwa uses to load the configuration 25 | const root = nuxt.options.vite.root ?? process.cwd() 26 | const { config, sources } = await loadConfiguration(root, configuration) 27 | if (!config.preset) 28 | return 29 | 30 | const { 31 | preset, 32 | images, 33 | headLinkOptions: userHeadLinkOptions, 34 | } = config 35 | if (!images) 36 | return 37 | 38 | if (Array.isArray(images) && (!images.length || images.length > 1)) 39 | return 40 | 41 | const useImage = Array.isArray(images) ? images[0] : images 42 | const imageFile = await tryToResolveImage(ctx, useImage) 43 | const publicDir = ctx.publicDirFolder 44 | const imageName = relative(publicDir, imageFile) 45 | 46 | const xhtml = userHeadLinkOptions?.xhtml === true 47 | const includeId = userHeadLinkOptions?.includeId === true 48 | const assetsInstructions = await instructions({ 49 | imageResolver: () => readFile(imageFile), 50 | imageName, 51 | preset, 52 | faviconPreset: userHeadLinkOptions?.preset, 53 | htmlLinks: { xhtml, includeId }, 54 | basePath: nuxt.options.app.baseURL ?? '/', 55 | resolveSvgName: userHeadLinkOptions?.resolveSvgName ?? (name => basename(name)), 56 | }) 57 | const transparentNames = Object.values(assetsInstructions.transparent).map(({ name }) => name) 58 | const maskableNames = Object.values(assetsInstructions.maskable).map(({ name }) => name) 59 | const faviconNames = Object.values(assetsInstructions.favicon).map(({ name }) => name) 60 | const appleNames = Object.values(assetsInstructions.apple).map(({ name }) => name) 61 | const appleSplashScreenNames = Object.values(assetsInstructions.appleSplashScreen).map(({ name }) => name) 62 | const dts = { 63 | dts: pwaIcons({ 64 | transparent: transparentNames, 65 | maskable: maskableNames, 66 | favicon: faviconNames, 67 | apple: appleNames, 68 | appleSplashScreen: appleSplashScreenNames, 69 | }), 70 | transparent: generatePwaImageType('PwaTransparentImage', ctx.nuxt4, transparentNames), 71 | maskable: generatePwaImageType('PwaMaskableImage', ctx.nuxt4, maskableNames), 72 | favicon: generatePwaImageType('PwaFaviconImage', ctx.nuxt4, faviconNames), 73 | apple: generatePwaImageType('PwaAppleImage', ctx.nuxt4, appleNames), 74 | appleSplashScreen: generatePwaImageType('PwaAppleSplashScreenImage', ctx.nuxt4, appleSplashScreenNames), 75 | } satisfies DtsInfo 76 | 77 | if (nuxt.options.dev && nuxt.options.ssr) { 78 | // restart nuxt dev server when the configuration files change 79 | sources.forEach(source => nuxt.options.watch.push(source.replace(/\\/g, '/'))) 80 | } 81 | 82 | return dts 83 | } 84 | 85 | async function resolvePWAAssetsOptions(ctx: NuxtPWAContext) { 86 | const { options, nuxt } = ctx 87 | if (!options.pwaAssets) 88 | return 89 | 90 | const { 91 | disabled: useDisabled, 92 | config, 93 | preset, 94 | image, 95 | htmlPreset = '2023', 96 | overrideManifestIcons = false, 97 | includeHtmlHeadLinks = true, 98 | injectThemeColor = true, 99 | integration, 100 | } = options.pwaAssets ?? {} 101 | 102 | const disabled = !(useDisabled === true) ? false : (!config && !preset) 103 | let useImage: string 104 | if (image) { 105 | useImage = await tryToResolveImage(ctx, image) 106 | } 107 | else { 108 | useImage = resolve(ctx.publicDirFolder, 'favicon.svg') 109 | } 110 | 111 | // pwa plugin will use vite.root, and so, we need to always resolve the image relative to srcDir 112 | // - Nuxt 3: srcDir === rootDir 113 | // - Nuxt 3 v4 compat mode or Nuxt 4+: srcDir = /app vite.root set to srcDir 114 | useImage = relative(nuxt.options.srcDir, useImage) 115 | 116 | // prepare 117 | options.pwaAssets = { 118 | disabled, 119 | config: disabled || !config ? false : config, 120 | preset: disabled || config ? false : preset ?? 'minimal-2023', 121 | image: useImage, 122 | htmlPreset, 123 | overrideManifestIcons, 124 | includeHtmlHeadLinks, 125 | injectThemeColor, 126 | integration, 127 | } 128 | 129 | return { 130 | disabled, 131 | config: disabled || !config ? false : config, 132 | preset: disabled || config ? false : preset ?? 'minimal-2023', 133 | images: [useImage], 134 | htmlPreset, 135 | overrideManifestIcons, 136 | includeHtmlHeadLinks, 137 | injectThemeColor, 138 | integration, 139 | } 140 | } 141 | 142 | async function loadConfiguration( 143 | root: string, 144 | pwaAssets: ResolvedPWAAssetsOptions, 145 | ) { 146 | if (pwaAssets.config === false) { 147 | return await loadConfig(root, { 148 | config: false, 149 | preset: pwaAssets.preset as UserConfig['preset'], 150 | images: pwaAssets.images, 151 | logLevel: 'silent', 152 | }) 153 | } 154 | 155 | return await loadConfig( 156 | root, 157 | typeof pwaAssets.config === 'boolean' 158 | ? root 159 | : { config: pwaAssets.config }, 160 | ) 161 | } 162 | 163 | async function checkFileExists(pathname: string): Promise { 164 | try { 165 | await access(pathname, fs.constants.R_OK) 166 | } 167 | catch { 168 | return false 169 | } 170 | 171 | return true 172 | } 173 | 174 | async function tryToResolveImage( 175 | ctx: NuxtPWAContext, 176 | imageName: string, 177 | ): Promise { 178 | for (const image of [ 179 | // rootDir 180 | resolve(ctx.nuxt.options.rootDir, imageName), 181 | // srcDir 182 | resolve(ctx.nuxt.options.srcDir, imageName), 183 | // publicDir 184 | resolve(ctx.publicDirFolder, imageName), 185 | ]) { 186 | if (await checkFileExists(image)) 187 | return image 188 | } 189 | throw new Error(`PWA Assets image '${imageName}' cannot be resolved!`) 190 | } 191 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import type { NitroConfig } from 'nitropack' 2 | import type { NuxtPWAContext } from '../context' 3 | import { createHash } from 'node:crypto' 4 | import { createReadStream } from 'node:fs' 5 | import { lstat } from 'node:fs/promises' 6 | import { resolve } from 'pathe' 7 | 8 | export function configurePWAOptions( 9 | ctx: NuxtPWAContext, 10 | nitroConfig: NitroConfig, 11 | ) { 12 | const { nuxt3_8, options, nuxt } = ctx 13 | if (!options.outDir) { 14 | const publicDir = nitroConfig.output?.publicDir ?? nuxt.options.nitro?.output?.publicDir 15 | options.outDir = publicDir ? resolve(publicDir) : resolve(nuxt.options.buildDir, '../.output/public') 16 | } 17 | 18 | // generate dev sw in .nuxt folder: we don't need to remove it 19 | if (options.devOptions?.enabled) 20 | options.devOptions.resolveTempFolder = () => resolve(nuxt.options.buildDir, 'dev-sw-dist') 21 | 22 | let config: Partial< 23 | import('workbox-build').BasePartial 24 | & import('workbox-build').GlobPartial 25 | & import('workbox-build').RequiredGlobDirectoryPartial 26 | > 27 | 28 | if (options.strategies === 'injectManifest') { 29 | options.injectManifest = options.injectManifest ?? {} 30 | config = options.injectManifest 31 | } 32 | else { 33 | options.strategies = 'generateSW' 34 | options.workbox = options.workbox ?? {} 35 | if (options.registerType === 'autoUpdate' && (options.client?.registerPlugin || options.injectRegister === 'script' || options.injectRegister === 'inline')) { 36 | options.workbox.clientsClaim = true 37 | options.workbox.skipWaiting = true 38 | } 39 | if (nuxt.options.dev) { 40 | // on dev force always to use the root 41 | options.workbox.navigateFallback = options.workbox.navigateFallback ?? nuxt.options.app.baseURL ?? '/' 42 | if (options.devOptions?.enabled && !options.devOptions.navigateFallbackAllowlist) { 43 | const baseURL = nuxt.options.app.baseURL 44 | // fix #214 45 | options.devOptions.navigateFallbackAllowlist = [baseURL 46 | ? new RegExp(`^${baseURL.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}$`) 47 | : /^\/$/] 48 | } 49 | } 50 | // the user may want to disable offline support 51 | if (!('navigateFallback' in options.workbox)) 52 | options.workbox.navigateFallback = nuxt.options.app.baseURL ?? '/' 53 | 54 | config = options.workbox 55 | } 56 | 57 | let buildAssetsDir = nuxt.options.app.buildAssetsDir ?? '_nuxt/' 58 | if (buildAssetsDir[0] === '/') 59 | buildAssetsDir = buildAssetsDir.slice(1) 60 | if (buildAssetsDir[buildAssetsDir.length - 1] !== '/') 61 | buildAssetsDir += '/' 62 | 63 | // Vite 5 support: allow override dontCacheBustURLsMatching 64 | if (!('dontCacheBustURLsMatching' in config)) 65 | config.dontCacheBustURLsMatching = new RegExp(buildAssetsDir) 66 | 67 | // handle payload extraction 68 | if (nuxt.options.experimental.payloadExtraction) { 69 | const enableGlobPatterns = nuxt.options.nitro.static || (nuxt.options as any)._generate /* TODO: remove in future */ 70 | || ( 71 | !!nitroConfig.prerender?.routes?.length 72 | || Object.values(nitroConfig.routeRules ?? {}).some(r => r.prerender) 73 | ) 74 | if (enableGlobPatterns) { 75 | config.globPatterns = config.globPatterns ?? [] 76 | config.globPatterns.push('**/_payload.json') 77 | if (options.strategies === 'generateSW' && options.experimental?.enableWorkboxPayloadQueryParams) { 78 | options.workbox!.runtimeCaching = options.workbox!.runtimeCaching ?? [] 79 | options.workbox!.runtimeCaching.push({ 80 | urlPattern: /\/_payload\.json\?/, 81 | handler: 'NetworkOnly', 82 | options: { 83 | plugins: [{ 84 | /* this callback will be called when the fetch call fails */ 85 | handlerDidError: async ({ request }) => { 86 | const url = new URL(request.url) 87 | url.search = '' 88 | return Response.redirect(url.href, 302) 89 | }, 90 | /* this callback will prevent caching the response */ 91 | cacheWillUpdate: async () => null, 92 | }], 93 | }, 94 | }) 95 | } 96 | } 97 | } 98 | 99 | // handle Nuxt App Manifest 100 | let appManifestFolder: string | undefined 101 | if (nuxt3_8 && nuxt.options.experimental.appManifest) { 102 | config.globPatterns = config.globPatterns ?? [] 103 | appManifestFolder = `${buildAssetsDir}builds/` 104 | config.globPatterns.push(`${appManifestFolder}**/*.json`) 105 | } 106 | 107 | // allow override manifestTransforms 108 | if (!nuxt.options.dev && !config.manifestTransforms) 109 | config.manifestTransforms = [createManifestTransform(nuxt.options.app.baseURL ?? '/', options.outDir, appManifestFolder)] 110 | 111 | if (options.pwaAssets) { 112 | options.pwaAssets.integration = { 113 | baseUrl: nuxt.options.app.baseURL ?? '/', 114 | publicDir: ctx.publicDirFolder, 115 | outDir: options.outDir, 116 | } 117 | } 118 | 119 | const integration = options.integration ?? {} 120 | const { 121 | beforeBuildServiceWorker: original, 122 | ...rest 123 | } = integration 124 | options.integration = { 125 | ...rest, 126 | async beforeBuildServiceWorker(resolvedPwaOptions) { 127 | await original?.(resolvedPwaOptions) 128 | await nuxt.callHook('pwa:beforeBuildServiceWorker', resolvedPwaOptions) 129 | }, 130 | } 131 | } 132 | 133 | function createManifestTransform( 134 | base: string, 135 | publicFolder: string, 136 | appManifestFolder?: string, 137 | ): import('workbox-build').ManifestTransform { 138 | return async (entries) => { 139 | entries.filter(e => e.url.endsWith('.html')).forEach((e) => { 140 | const url = e.url.startsWith('/') ? e.url.slice(1) : e.url 141 | if (url === 'index.html') { 142 | e.url = base 143 | } 144 | else { 145 | const parts = url.split('/') 146 | parts[parts.length - 1] = parts[parts.length - 1].replace(/\.html$/, '') 147 | e.url = parts.length > 1 ? parts.slice(0, parts.length - 1).join('/') : parts[0] 148 | } 149 | }) 150 | 151 | if (appManifestFolder) { 152 | // this shouldn't be necessary, since we are using dontCacheBustURLsMatching 153 | // eslint-disable-next-line regexp/no-unused-capturing-group,regexp/no-useless-assertions 154 | const regExp = /(\/)?[0-9a-f]{8}\b-[0-9a-f]{4}\b-[0-9a-f]{4}\b-[0-9a-f]{4}\b-[0-9a-f]{12}\.json$/i 155 | // we need to remove the revision from the sw prechaing manifest, UUID is enough: 156 | // we don't use dontCacheBustURLsMatching, single regex 157 | entries.filter(e => e.url.startsWith(appManifestFolder) && regExp.test(e.url)).forEach((e) => { 158 | e.revision = null 159 | }) 160 | // add revision to latest.json file: we are excluding `_nuxt/` assets from dontCacheBustURLsMatching 161 | const latest = `${appManifestFolder}latest.json` 162 | const latestJson = resolve(publicFolder, latest) 163 | const data = await lstat(latestJson).catch(() => undefined) 164 | if (data?.isFile()) { 165 | const revision = await new Promise((resolve, reject) => { 166 | const cHash = createHash('MD5') 167 | const stream = createReadStream(latestJson) 168 | stream.on('error', (err) => { 169 | reject(err) 170 | }) 171 | stream.on('data', chunk => cHash.update(chunk)) 172 | stream.on('end', () => { 173 | resolve(cHash.digest('hex')) 174 | }) 175 | }) 176 | 177 | const latestEntry = entries.find(e => e.url === latest) 178 | if (latestEntry) 179 | latestEntry.revision = revision 180 | else 181 | entries.push({ url: latest, revision, size: data.size }) 182 | } 183 | else { 184 | entries = entries.filter(e => e.url !== latest) 185 | } 186 | } 187 | 188 | return { manifest: entries, warnings: [] } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/utils/module.ts: -------------------------------------------------------------------------------- 1 | import type { Nuxt } from '@nuxt/schema' 2 | import type { Plugin } from 'vite' 3 | import type { VitePluginPWAAPI } from 'vite-plugin-pwa' 4 | import type { NuxtPWAContext } from '../context' 5 | import type { PwaModuleOptions } from '../types' 6 | import { mkdir } from 'node:fs/promises' 7 | import { join } from 'node:path' 8 | import { 9 | addComponent, 10 | addDevServerHandler, 11 | addPlugin, 12 | createResolver, 13 | extendWebpackConfig, 14 | getNuxtVersion, 15 | resolveAlias, 16 | } from '@nuxt/kit' 17 | import { VitePWA } from 'vite-plugin-pwa' 18 | import { configurePWAOptions } from './config' 19 | import { addPWAIconsPluginTemplate } from './pwa-icons-helper' 20 | import { registerPwaIconsTypes } from './pwa-icons-types' 21 | import { regeneratePWA, writeWebManifest } from './utils' 22 | 23 | export async function doSetup(options: PwaModuleOptions, nuxt: Nuxt) { 24 | const resolver = createResolver(import.meta.url) 25 | const nuxtVersion = (getNuxtVersion(nuxt) as string).split('.').map(v => Number.parseInt(v)) 26 | const nuxt3_8 = nuxtVersion.length > 1 && (nuxtVersion[0] > 3 || (nuxtVersion[0] === 3 && nuxtVersion[1] >= 8)) 27 | const nuxt4 = nuxtVersion.length > 1 && nuxtVersion[0] >= 4 28 | const future = nuxt.options.future as any 29 | 30 | const ctx: NuxtPWAContext = { 31 | nuxt, 32 | nuxt3_8, 33 | nuxt4, 34 | // included at v3.12.0, then removed and included back again for v5 compatibility: we only need the layout to configure correctly the pwa assets image 35 | // https://github.com/nuxt/nuxt/releases/tag/v3.12.0 36 | nuxt4Compat: nuxt4 ? true : ('compatibilityVersion' in future && typeof future.compatibilityVersion === 'number') && future.compatibilityVersion >= 4, 37 | resolver: createResolver(import.meta.url), 38 | options, 39 | publicDirFolder: resolveAlias('public'), 40 | } 41 | 42 | let vitePwaClientPlugin: Plugin | undefined 43 | const resolveVitePluginPWAAPI = (): VitePluginPWAAPI | undefined => { 44 | return vitePwaClientPlugin?.api 45 | } 46 | 47 | const client = options.client ?? { 48 | registerPlugin: true, 49 | installPrompt: false, 50 | periodicSyncForUpdates: 0, 51 | } 52 | /* if (client.registerPlugin) { 53 | addPluginTemplate({ 54 | src: resolver.resolve('../templates/pwa.client.ts'), 55 | write: nuxt.options.dev || options.writePlugin, 56 | options: { 57 | periodicSyncForUpdates: typeof client.periodicSyncForUpdates === 'number' ? client.periodicSyncForUpdates : 0, 58 | installPrompt: (typeof client.installPrompt === 'undefined' || client.installPrompt === false) 59 | ? undefined 60 | : (client.installPrompt === true || client.installPrompt.trim() === '') 61 | ? 'vite-pwa:hide-install' 62 | : client.installPrompt.trim(), 63 | }, 64 | }) 65 | } */ 66 | 67 | const runtimeDir = resolver.resolve('../runtime') 68 | nuxt.options.build.transpile.push(runtimeDir) 69 | 70 | if (client.registerPlugin) { 71 | addPlugin({ 72 | src: resolver.resolve(runtimeDir, 'plugins/pwa.client'), 73 | mode: 'client', 74 | }) 75 | } 76 | 77 | const pwaAssetsEnabled = !!options.pwaAssets && options.pwaAssets.disabled !== true 78 | 79 | addPWAIconsPluginTemplate(pwaAssetsEnabled) 80 | 81 | if (ctx.nuxt4) { 82 | await Promise.all([ 83 | addComponent({ 84 | name: 'VitePwaManifest', 85 | filePath: resolver.resolve(runtimeDir, 'components/VitePwaManifest'), 86 | }), 87 | addComponent({ 88 | name: 'NuxtPwaManifest', 89 | filePath: resolver.resolve(runtimeDir, 'components/VitePwaManifest'), 90 | }), 91 | addComponent({ 92 | name: 'NuxtPwaAssets', 93 | filePath: resolver.resolve(runtimeDir, 'components/NuxtPwaAssets'), 94 | }), 95 | addComponent({ 96 | name: 'PwaAppleImage', 97 | filePath: resolver.resolve(runtimeDir, 'components/nuxt4/PwaAppleImage'), 98 | }), 99 | addComponent({ 100 | name: 'PwaAppleSplashScreenImage', 101 | filePath: resolver.resolve(runtimeDir, 'components/nuxt4/PwaAppleSplashScreenImage'), 102 | }), 103 | addComponent({ 104 | name: 'PwaFaviconImage', 105 | filePath: resolver.resolve(runtimeDir, 'components/nuxt4/PwaFaviconImage'), 106 | }), 107 | addComponent({ 108 | name: 'PwaMaskableImage', 109 | filePath: resolver.resolve(runtimeDir, 'components/nuxt4/PwaMaskableImage'), 110 | }), 111 | addComponent({ 112 | name: 'PwaTransparentImage', 113 | filePath: resolver.resolve(runtimeDir, 'components/nuxt4/PwaTransparentImage'), 114 | }), 115 | ]) 116 | } 117 | else { 118 | await Promise.all([ 119 | addComponent({ 120 | name: 'VitePwaManifest', 121 | filePath: resolver.resolve(runtimeDir, 'components/VitePwaManifest'), 122 | }), 123 | addComponent({ 124 | name: 'NuxtPwaManifest', 125 | filePath: resolver.resolve(runtimeDir, 'components/VitePwaManifest'), 126 | }), 127 | addComponent({ 128 | name: 'NuxtPwaAssets', 129 | filePath: resolver.resolve(runtimeDir, 'components/NuxtPwaAssets'), 130 | }), 131 | addComponent({ 132 | name: 'PwaAppleImage', 133 | filePath: resolver.resolve(runtimeDir, 'components/PwaAppleImage.vue'), 134 | }), 135 | addComponent({ 136 | name: 'PwaAppleSplashScreenImage', 137 | filePath: resolver.resolve(runtimeDir, 'components/PwaAppleSplashScreenImage.vue'), 138 | }), 139 | addComponent({ 140 | name: 'PwaFaviconImage', 141 | filePath: resolver.resolve(runtimeDir, 'components/PwaFaviconImage.vue'), 142 | }), 143 | addComponent({ 144 | name: 'PwaMaskableImage', 145 | filePath: resolver.resolve(runtimeDir, 'components/PwaMaskableImage.vue'), 146 | }), 147 | addComponent({ 148 | name: 'PwaTransparentImage', 149 | filePath: resolver.resolve(runtimeDir, 'components/PwaTransparentImage.vue'), 150 | }), 151 | ]) 152 | } 153 | 154 | nuxt.hook('prepare:types', ({ references }) => { 155 | references.push({ path: resolver.resolve(runtimeDir, 'plugins/types') }) 156 | references.push({ types: '@vite-pwa/nuxt/configuration' }) 157 | references.push({ types: 'vite-plugin-pwa/vue' }) 158 | references.push({ types: 'vite-plugin-pwa/info' }) 159 | references.push({ types: 'vite-plugin-pwa/pwa-assets' }) 160 | }) 161 | 162 | const pwaAssets = await registerPwaIconsTypes(ctx, runtimeDir) 163 | 164 | const manifestDir = join(nuxt.options.buildDir, 'manifests') 165 | nuxt.options.nitro.publicAssets = nuxt.options.nitro.publicAssets || [] 166 | nuxt.options.nitro.publicAssets.push({ 167 | dir: manifestDir, 168 | maxAge: 0, 169 | }) 170 | 171 | nuxt.hook('nitro:init', (nitro) => { 172 | configurePWAOptions(ctx, nitro.options) 173 | }) 174 | 175 | nuxt.hook('vite:extend', ({ config }) => { 176 | const plugin = config.plugins?.find(p => p && typeof p === 'object' && 'name' in p && p.name === 'vite-plugin-pwa') 177 | if (plugin) 178 | throw new Error('Remove vite-plugin-pwa plugin from Vite Plugins entry in Nuxt config file!') 179 | }) 180 | 181 | nuxt.hook('vite:extendConfig', async (viteInlineConfig, { isClient }) => { 182 | viteInlineConfig.plugins = viteInlineConfig.plugins || [] 183 | const plugin = viteInlineConfig.plugins.find(p => p && typeof p === 'object' && 'name' in p && p.name === 'vite-plugin-pwa') 184 | if (plugin) 185 | throw new Error('Remove vite-plugin-pwa plugin from Vite Plugins entry in Nuxt config file!') 186 | 187 | if (options.manifest && isClient) { 188 | viteInlineConfig.plugins.push({ 189 | name: 'vite-pwa-nuxt:webmanifest:build', 190 | apply: 'build', 191 | async writeBundle(_options, bundle) { 192 | if (options.disable || !bundle) 193 | return 194 | 195 | const api = resolveVitePluginPWAAPI() 196 | if (api) { 197 | await mkdir(manifestDir, { recursive: true }) 198 | await writeWebManifest(manifestDir, options.manifestFilename || 'manifest.webmanifest', api, pwaAssets) 199 | } 200 | }, 201 | }) 202 | } 203 | 204 | if (isClient) { 205 | viteInlineConfig.plugins = viteInlineConfig.plugins || [] 206 | const configuration = 'virtual:nuxt-pwa-configuration' 207 | const resolvedConfiguration = `\0${configuration}` 208 | viteInlineConfig.plugins.push({ 209 | name: 'vite-pwa-nuxt:configuration', 210 | enforce: 'pre', 211 | resolveId(id) { 212 | if (id === configuration) 213 | return resolvedConfiguration 214 | }, 215 | load(id) { 216 | if (id === resolvedConfiguration) { 217 | const display = typeof options.manifest !== 'boolean' ? options.manifest?.display ?? 'standalone' : 'standalone' 218 | const installPrompt = (typeof client.installPrompt === 'undefined' || client.installPrompt === false) 219 | ? undefined 220 | : (client.installPrompt === true || client.installPrompt.trim() === '') 221 | ? 'vite-pwa:hide-install' 222 | : client.installPrompt.trim() 223 | return `export const enabled = ${client.registerPlugin} 224 | export const display = '${display}' 225 | export const installPrompt = ${JSON.stringify(installPrompt)} 226 | export const periodicSyncForUpdates = ${typeof client.periodicSyncForUpdates === 'number' ? client.periodicSyncForUpdates : 0} 227 | ` 228 | } 229 | }, 230 | }) 231 | } 232 | 233 | // remove vite plugin pwa build plugin 234 | const plugins = [...VitePWA(options).filter(p => p.name !== 'vite-plugin-pwa:build')] 235 | viteInlineConfig.plugins.push(plugins) 236 | if (isClient) 237 | vitePwaClientPlugin = plugins.find(p => p.name === 'vite-plugin-pwa') as Plugin 238 | }) 239 | 240 | extendWebpackConfig(() => { 241 | throw new Error('Webpack is not supported: @vite-pwa/nuxt module can only be used with Vite!') 242 | }) 243 | 244 | if (nuxt.options.dev) { 245 | const webManifest = `${nuxt.options.app.baseURL}${options.devOptions?.webManifestUrl ?? options.manifestFilename ?? 'manifest.webmanifest'}` 246 | const devSw = `${nuxt.options.app.baseURL}dev-sw.js?dev-sw` 247 | const workbox = `${nuxt.options.app.baseURL}workbox-` 248 | // @ts-expect-error just ignore 249 | const emptyHandle = (_req, _res, next) => { 250 | next() 251 | } 252 | nuxt.hook('vite:serverCreated', (viteServer, { isServer }) => { 253 | if (isServer) 254 | return 255 | 256 | viteServer.middlewares.stack.push({ route: webManifest, handle: emptyHandle }) 257 | viteServer.middlewares.stack.push({ route: devSw, handle: emptyHandle }) 258 | if (options.pwaAssets) { 259 | viteServer.middlewares.stack.push({ 260 | route: '', 261 | // @ts-expect-error just ignore 262 | handle: async (req, res, next) => { 263 | const url = req.url 264 | if (!url) 265 | return next() 266 | 267 | if (!/\.(?:ico|png|svg|webp)$/.test(url)) 268 | return next() 269 | 270 | const pwaAssetsGenerator = await resolveVitePluginPWAAPI()?.pwaAssetsGenerator() 271 | if (!pwaAssetsGenerator) 272 | return next() 273 | 274 | const icon = await pwaAssetsGenerator.findIconAsset(url) 275 | if (!icon) 276 | return next() 277 | 278 | if (icon.age > 0) { 279 | const ifModifiedSince = req.headers['if-modified-since'] ?? req.headers['If-Modified-Since'] 280 | const useIfModifiedSince = ifModifiedSince ? Array.isArray(ifModifiedSince) ? ifModifiedSince[0] : ifModifiedSince : undefined 281 | if (useIfModifiedSince && new Date(icon.lastModified).getTime() / 1000 >= new Date(useIfModifiedSince).getTime() / 1000) { 282 | res.statusCode = 304 283 | res.end() 284 | return 285 | } 286 | } 287 | 288 | const buffer = await icon.buffer 289 | res.setHeader('Age', icon.age / 1000) 290 | res.setHeader('Content-Type', icon.mimeType) 291 | res.setHeader('Content-Length', buffer.length) 292 | res.setHeader('Last-Modified', new Date(icon.lastModified).toUTCString()) 293 | res.statusCode = 200 294 | res.end(buffer) 295 | }, 296 | }) 297 | } 298 | }) 299 | 300 | if (!options.strategies || options.strategies === 'generateSW') { 301 | nuxt.hook('vite:serverCreated', (viteServer, { isServer }) => { 302 | if (isServer) 303 | return 304 | 305 | viteServer.middlewares.stack.push({ route: workbox, handle: emptyHandle }) 306 | }) 307 | if (options.devOptions?.suppressWarnings) { 308 | const suppressWarnings = `${nuxt.options.app.baseURL}suppress-warnings.js` 309 | nuxt.hook('vite:serverCreated', (viteServer, { isServer }) => { 310 | if (isServer) 311 | return 312 | 313 | viteServer.middlewares.stack.push({ route: suppressWarnings, handle: emptyHandle }) 314 | }) 315 | } 316 | const { sourcemap = nuxt.options.sourcemap.client === true } = options.workbox ?? {} 317 | if (sourcemap) { 318 | const swMap = `${nuxt.options.app.baseURL}${options.filename ?? 'sw.js'}.map` 319 | const resolvedSwMapFile = join(nuxt.options.buildDir, 'dev-sw-dist', swMap) 320 | const worboxMap = `${nuxt.options.app.baseURL}workbox-` 321 | addDevServerHandler({ 322 | route: '', 323 | handler: await import('h3').then(({ defineLazyEventHandler }) => defineLazyEventHandler(async () => { 324 | const { dev } = await import('./dev') 325 | return dev( 326 | swMap, 327 | resolvedSwMapFile, 328 | worboxMap, 329 | nuxt.options.buildDir, 330 | nuxt.options.app.baseURL, 331 | ) 332 | })), 333 | }) 334 | } 335 | } 336 | } 337 | else { 338 | if (!options.disable && options.registerWebManifestInRouteRules) { 339 | nuxt.hook('nitro:config', async (nitroConfig) => { 340 | nitroConfig.routeRules = nitroConfig.routeRules || {} 341 | let swName = options.filename || 'sw.js' 342 | if (options.strategies === 'injectManifest' && swName.endsWith('.ts')) 343 | swName = swName.replace(/\.ts$/, '.js') 344 | 345 | nitroConfig.routeRules[`${nuxt.options.app.baseURL}${swName}`] = { 346 | headers: { 347 | 'Cache-Control': 'public, max-age=0, must-revalidate', 348 | }, 349 | } 350 | // if provided by the user, we don't know web manifest name 351 | if (options.manifest) { 352 | nitroConfig.routeRules[`${nuxt.options.app.baseURL}${options.manifestFilename ?? 'manifest.webmanifest'}`] = { 353 | headers: { 354 | 'Content-Type': 'application/manifest+json', 355 | 'Cache-Control': 'public, max-age=0, must-revalidate', 356 | }, 357 | } 358 | } 359 | }) 360 | } 361 | if (nuxt3_8) { 362 | nuxt.hook('nitro:build:public-assets', async () => { 363 | await regeneratePWA( 364 | options.outDir!, 365 | pwaAssets, 366 | resolveVitePluginPWAAPI(), 367 | ) 368 | }) 369 | } 370 | else { 371 | nuxt.hook('nitro:init', (nitro) => { 372 | nitro.hooks.hook('rollup:before', async () => { 373 | await regeneratePWA( 374 | options.outDir!, 375 | pwaAssets, 376 | resolveVitePluginPWAAPI(), 377 | ) 378 | }) 379 | }) 380 | if (nuxt.options.nitro.static || (nuxt.options as any)._generate /* TODO: remove in future */) { 381 | nuxt.hook('close', async () => { 382 | await regeneratePWA( 383 | options.outDir!, 384 | pwaAssets, 385 | resolveVitePluginPWAAPI(), 386 | ) 387 | }) 388 | } 389 | } 390 | } 391 | } 392 | --------------------------------------------------------------------------------