import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import type { ConfigEnv, UserConfig } from 'vite' import type { VitePluginPWAAPI, VitePWAOptions } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa' import type { ManifestTransform } from 'workbox-build' export interface PwaOptions extends Partial { experimental?: { /** * When using `generateSW` strategy, include custom directory and trailing slash handler. * * @see https://github.com/vite-pwa/astro/issues/23 * * @default false */ directoryAndTrailingSlashHandler?: boolean } } interface PWAContext { api?: VitePluginPWAAPI previewOrSync: boolean doBuild: boolean scope: string useDirectoryFormat: boolean trailingSlash: 'never' | 'always' | 'ignore' outDir: string assetsDir: string publicDir: string clientDist: string } export function withPwaPlugin( options: PwaOptions = {}, config: UserConfig, env: ConfigEnv, ): UserConfig { const ctx: PWAContext = { api: undefined, previewOrSync: false, doBuild: false, trailingSlash: 'ignore', useDirectoryFormat: true, scope: config.base ?? '/', outDir: config.build?.outDir ?? 'dist', assetsDir: config.build?.assetsDir ?? 'assets/', publicDir: config.publicDir || 'public', clientDist: '.tanstack/start/build/client-dist', } const { command } = env if (command === 'serve') { ctx.previewOrSync = true // return config } // ctx.trailingSlash = config.trailingSlash // ctx.useDirectoryFormat = config.build.format === 'directory' let plugins = getViteConfiguration(config, options, ctx.useDirectoryFormat) plugins = plugins.filter(p => 'name' in p && p.name !== 'vite-plugin-pwa:build') if (command === 'build') { plugins = plugins.filter(p => 'name' in p && p.name !== 'vite-plugin-pwa:dev-sw') plugins.push({ name: 'vite-pwa:tanstack:build:plugin', enforce: 'post', applyToEnvironment(env) { return env.name === 'client' }, configResolved(resolvedConfig) { // console.log('old ctx', { ...ctx }) // ctx.scope = resolvedConfig.base ?? '/' // ctx.assetsDir = resolvedConfig.build?.assetsDir ?? 'assets/' // ctx.publicDir = resolvedConfig.publicDir || 'public' // ctx.outDir = resolvedConfig.build?.outDir ?? 'dist' // console.log('new ctx', { ...ctx }) // look up the PWA api from the client pipeline: // this will prevent generating web manifest and registerSW in the server folder if (!resolvedConfig.build.ssr) { ctx.api = resolvedConfig .plugins!.flat(Number.POSITIVE_INFINITY) .find(p => p.name === 'vite-plugin-pwa')?.api } }, async generateBundle(_, bundle) { const api = ctx.api if (api) { const pwaAssetsGenerator = await api.pwaAssetsGenerator() if (pwaAssetsGenerator) { pwaAssetsGenerator.injectManifestIcons() } api.generateBundle(bundle as any, this as any) } }, // async buildEnd(_error) {}, closeBundle: { sequential: true, order: 'post', async handler() { if (ctx.previewOrSync) { return } ctx.doBuild = true const api = ctx.api if (api && !api.disabled) { await api.generateSW() const files = await fs.readdir(ctx.outDir) await Promise.all( files.map(async file => { const source = path.join(ctx.outDir, file) const destination = path.join(ctx.clientDist, file) // const destination = path.join(ctx.clientDist, file === 'sw.js' ? 'og-sw.js' : file) await fs.copyFile(source, destination) // if (file === 'sw.js') { // await fs.rm(source, { force: true, recursive: true }) // } }), ) await fs.rm(ctx.outDir, { recursive: true }) } const pwaAssetsGenerator = api && (await api.pwaAssetsGenerator()) if (pwaAssetsGenerator) { await pwaAssetsGenerator.generate() } }, }, }) } config.plugins ??= [] config.plugins.push(...plugins) function createManifestTransform(): ManifestTransform { return async entries => { const { doBuild, trailingSlash, scope, useDirectoryFormat } = ctx if (doBuild) { // apply transformation only when build enabled entries .filter(e => e && e.url.endsWith('.html')) .forEach(e => { const url = e.url.startsWith('/') ? e.url.slice(1) : e.url if (url === 'index.html') { e.url = scope } else { const parts = url.split('/') parts[parts.length - 1] = parts[parts.length - 1].replace(/\.html$/, '') e.url = useDirectoryFormat ? parts.length > 1 ? parts.slice(0, parts.length - 1).join('/') : parts[0] : parts.join('/') if (trailingSlash === 'always') { e.url += '/' } } }) } return { manifest: entries, warnings: [] } } } function createExperimentalManifestTransform(): ManifestTransform { return async entries => { const { doBuild, trailingSlash, scope, useDirectoryFormat } = ctx if (doBuild) { const additionalEntries: Parameters[0] = [] // apply transformation only when build enabled entries .filter(e => e && e.url.endsWith('.html')) .forEach(e => { const url = e.url.startsWith('/') ? e.url.slice(1) : e.url if (url === 'index.html') { additionalEntries.push({ revision: e.revision, url: scope, size: e.size, }) } else if (url === '404.html') { e.url = `404${trailingSlash === 'always' ? '/' : ''}` } else { const parts = url.split('/') parts[parts.length - 1] = parts[parts.length - 1].replace(/\.html$/, '') let newUrl = useDirectoryFormat ? parts.length > 1 ? parts.slice(0, parts.length - 1).join('/') : parts[0] : parts.join('/') if (trailingSlash === 'always') { newUrl += '/' } additionalEntries.push({ revision: e.revision, url: newUrl, size: e.size, }) } }) if (additionalEntries.length) { entries.push(...additionalEntries) } } return { manifest: entries, warnings: [] } } } function getViteConfiguration(config: UserConfig, options: PwaOptions, directoryFormat: boolean) { // @ts-expect-error TS2589: Type instantiation is excessively deep and possibly infinite. const plugin = config?.plugins ?.flat(Number.POSITIVE_INFINITY) .find(p => p.name === 'vite-plugin-pwa') if (plugin) { throw new Error( 'Remove the vite-plugin-pwa plugin from Vite Plugins entry in Astro config file, configure it via @vite-pwa/astro integration', ) } // icons are there when `astro:build:done` hook is called options.includeManifestIcons = false // const server = config.output === 'server' const server = true if (server) { // options.outDir = fileURLToPath(ctx.outDir) } if (options.pwaAssets) { options.pwaAssets.integration = { baseUrl: ctx.scope, publicDir: fileURLToPath(ctx.publicDir), outDir: server ? options.outDir : fileURLToPath(ctx.outDir), } } const { strategies = 'generateSW', registerType = 'prompt', injectRegister, workbox = {}, ...rest } = options let assets = ctx.assetsDir if (assets[0] === '/') { assets = assets.slice(1) } if (assets[assets.length - 1] !== '/') { assets += '/' } if (strategies === 'generateSW') { const useWorkbox = { ...workbox } const newOptions: Partial = { ...rest, strategies, registerType, injectRegister, } if (server) { useWorkbox.globDirectory = options.outDir useWorkbox.globDirectory = '.output/public' useWorkbox.globDirectory = '.tanstack/start/build/client-dist' } // the user may want to disable offline support if (!('navigateFallback' in useWorkbox)) { useWorkbox.navigateFallback = ctx.scope } if (directoryFormat) { useWorkbox.directoryIndex = 'index.html' } newOptions.workbox = useWorkbox // Astro4/ Vite5 support: allow override dontCacheBustURLsMatching if (!('dontCacheBustURLsMatching' in newOptions.workbox)) { newOptions.workbox.dontCacheBustURLsMatching = new RegExp(assets) } if (!newOptions.workbox.manifestTransforms) { newOptions.workbox.manifestTransforms = newOptions.workbox.manifestTransforms ?? [] newOptions.workbox.manifestTransforms.push( options.experimental?.directoryAndTrailingSlashHandler === true ? createExperimentalManifestTransform() : createManifestTransform(), ) } return VitePWA(newOptions) } options.injectManifest = options.injectManifest ?? {} if (server) { options.injectManifest.globDirectory = options.outDir } // Astro4/ Vite5 support: allow override dontCacheBustURLsMatching if (!('dontCacheBustURLsMatching' in options.injectManifest)) options.injectManifest.dontCacheBustURLsMatching = new RegExp(assets) if (!options.injectManifest.manifestTransforms) { options.injectManifest.manifestTransforms = options.injectManifest.manifestTransforms ?? [] options.injectManifest.manifestTransforms.push( options.experimental?.directoryAndTrailingSlashHandler === true ? createExperimentalManifestTransform() : createManifestTransform(), ) } return VitePWA(options) } return config }