Skip to content

Commit 06dbc58

Browse files
authored
feat: make PWA support an optional peer dependency (#2671)
1 parent 053bef8 commit 06dbc58

7 files changed

Lines changed: 174 additions & 14 deletions

File tree

docs/features/pwa.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ The `pwa` option can be a boolean or a string to control when the service worker
3131

3232
Since precaching every asset is heavy, `pwa` is **off by default** and should be enabled deliberately — most useful together with [`slidev build`](/guide/hosting) for a self-hosted deck you want to work offline.
3333

34+
## Installing the PWA Plugin
35+
36+
PWA support is powered by [`vite-plugin-pwa`](https://vite-pwa-org.netlify.app/), which ships as an **optional peer dependency**. It is not installed by default, so decks that don't opt into `pwa` never download it.
37+
38+
The first time you enable `pwa`, the Slidev CLI detects that the package is missing and prompts you to install it:
39+
40+
```
41+
? The "pwa" option requires the "vite-plugin-pwa" package, which is not installed
42+
in your project. Install it now? › (Y/n)
43+
```
44+
45+
Confirm the prompt and Slidev installs it for you with your project's package manager (or globally, when Slidev itself is installed globally). In a non-interactive environment (such as CI) the prompt can't be shown, so install it ahead of time instead:
46+
47+
```bash
48+
npm i -D vite-plugin-pwa
49+
```
50+
3451
## How It Works
3552

3653
When you serve the built deck, the service worker downloads and caches all deck assets in the background. A small indicator in the bottom-right corner shows `Caching for offline…` while precaching is in progress, then briefly shows `Ready offline` once it completes. After that, disconnecting the network and reloading serves the whole deck — HTML, images, and video — from the cache.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"tsx": "catalog:dev",
7272
"typescript": "catalog:dev",
7373
"vite": "catalog:prod",
74+
"vite-plugin-pwa": "catalog:prod",
7475
"vitest": "catalog:dev",
7576
"vue-tsc": "catalog:dev",
7677
"zx": "catalog:dev"

packages/client/shim.d.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
1-
/// <reference types="vite-plugin-pwa/client" />
1+
// `vite-plugin-pwa` is an optional peer dependency (only needed when the `pwa`
2+
// option is enabled), so declare the subset of `virtual:pwa-register` we use
3+
// inline instead of `/// <reference types="vite-plugin-pwa/client" />`. When
4+
// PWA is off, the CLI serves a no-op stub for this module.
5+
declare module 'virtual:pwa-register' {
6+
export interface RegisterSWOptions {
7+
immediate?: boolean
8+
onNeedRefresh?: () => void
9+
onOfflineReady?: () => void
10+
onRegisteredSW?: (swScriptUrl: string, registration: ServiceWorkerRegistration | undefined) => void
11+
onRegisterError?: (error: any) => void
12+
}
13+
14+
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>
15+
}
216

317
declare module '*.md' {
418
// with unplugin-vue-markdown, markdowns can be treat as Vue components

packages/slidev/node/resolver.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { parseNi, run } from '@antfu/ni'
77
import { ensurePrefix, slash } from '@antfu/utils'
88
import { underline, yellow } from 'ansis'
99
import globalDirs from 'global-directory'
10-
import { resolvePath } from 'mlly'
10+
import { resolve as resolveModuleUrl, resolvePath } from 'mlly'
1111
import { dirname, join, relative, resolve, sep } from 'pathe'
1212
import prompts from 'prompts'
1313
import { resolveGlobal } from 'resolve-global'
@@ -166,6 +166,78 @@ export async function resolveImportPath(importName: string, ensure = false) {
166166
throw new Error(`Failed to resolve package "${importName}"`)
167167
}
168168

169+
/**
170+
* Import an optional dependency (typically an optional peer dependency such as
171+
* `vite-plugin-pwa`) that the user may or may not have installed. Resolution is
172+
* attempted, in order, from the user's project root, the workspace root, the
173+
* global registry (when Slidev runs globally), and finally the cli's own
174+
* dependencies. Returns `undefined` when the package can't be resolved anywhere.
175+
*/
176+
export async function importOptionalDependency<T = any>(name: string): Promise<T | undefined> {
177+
const roots: string[] = []
178+
try {
179+
const { userRoot, userWorkspaceRoot } = await getRoots()
180+
roots.push(userRoot)
181+
if (userWorkspaceRoot !== userRoot)
182+
roots.push(userWorkspaceRoot)
183+
}
184+
catch { }
185+
186+
for (const root of roots) {
187+
try {
188+
return await import(await resolveModuleUrl(name, { url: pathToFileURL(`${root}${sep}`).href }))
189+
}
190+
catch { }
191+
}
192+
193+
if (isInstalledGlobally.value) {
194+
try {
195+
return await import(resolveGlobal(name))
196+
}
197+
catch { }
198+
}
199+
200+
try {
201+
return await import(name)
202+
}
203+
catch { }
204+
205+
return undefined
206+
}
207+
208+
/**
209+
* Prompt the user to install a missing optional dependency, then install it
210+
* with the detected package manager. Exits the process when the user declines
211+
* or when stdin isn't interactive (so it can't prompt).
212+
*
213+
* `purpose` describes why the package is needed, e.g. `The "pwa" option`.
214+
*/
215+
export async function promptForOptionalInstallation(pkgName: string, purpose: string): Promise<void> {
216+
// Check if stdin is available for prompts (i.e., is a TTY)
217+
if (!process.stdin.isTTY) {
218+
console.error(
219+
`${purpose} requires the "${pkgName}" package, which is not installed, and cannot prompt for installation. `
220+
+ `Install it with \`npm i -D ${pkgName}\` (or your package manager's equivalent) and try again.`,
221+
)
222+
process.exit(1)
223+
}
224+
225+
const { confirm } = await prompts({
226+
name: 'confirm',
227+
initial: 'Y',
228+
type: 'confirm',
229+
message: `${purpose} requires the "${yellow(pkgName)}" package, which is not installed ${underline(isInstalledGlobally.value ? 'globally' : 'in your project')}. Install it now?`,
230+
})
231+
232+
if (!confirm)
233+
process.exit(1)
234+
235+
if (isInstalledGlobally.value)
236+
await run(parseNi, ['-g', pkgName])
237+
else
238+
await run(parseNi, [pkgName])
239+
}
240+
169241
/**
170242
* Find the root of the package. If Slidev is installed globally, it will also search globally.
171243
*/

packages/slidev/node/vite/pwa.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
11
import type { ResolvedSlidevOptions } from '@slidev/types'
2-
import type { PluginOption } from 'vite'
3-
import { VitePWA } from 'vite-plugin-pwa'
2+
import type { Plugin, PluginOption } from 'vite'
3+
import { importOptionalDependency, promptForOptionalInstallation } from '../resolver'
44

5-
export function createPWAPlugin(options: ResolvedSlidevOptions): PluginOption {
5+
type VitePWAFn = (options: Record<string, any>) => PluginOption
6+
7+
const PWA_PACKAGE = 'vite-plugin-pwa'
8+
9+
export async function createPWAPlugin(options: ResolvedSlidevOptions): Promise<PluginOption> {
610
const matchMode = (mode: string | boolean) => mode === true || mode === options.mode
711
const enabled = matchMode(options.data.config.pwa)
812

13+
// PWA off (the default): `vite-plugin-pwa` is an optional peer dependency, so
14+
// don't require it at all. A tiny stub keeps the client's guarded
15+
// `import('virtual:pwa-register')` resolvable so no PWA install is needed.
16+
if (!enabled)
17+
return createPWARegisterStubPlugin()
18+
19+
const VitePWA = await resolveVitePWA()
20+
921
const base = options.base ?? '/'
1022
const title = options.data.config.title || 'Slidev'
1123

12-
// Always register the plugin so `virtual:pwa-register` resolves in every build
13-
// (dev included); disable service-worker generation unless `pwa` is enabled.
1424
return VitePWA({
15-
disable: !enabled,
1625
registerType: 'autoUpdate',
1726
injectRegister: null,
1827
base,
@@ -34,5 +43,46 @@ export function createPWAPlugin(options: ResolvedSlidevOptions): PluginOption {
3443
theme_color: '#121212',
3544
background_color: '#121212',
3645
},
37-
}) as PluginOption
46+
})
47+
}
48+
49+
/**
50+
* Resolve `vite-plugin-pwa`'s `VitePWA` factory, prompting the user to install
51+
* the optional peer dependency when the `pwa` option is enabled but the package
52+
* is missing.
53+
*/
54+
async function resolveVitePWA(): Promise<VitePWAFn> {
55+
let mod = await importOptionalDependency<{ VitePWA?: VitePWAFn, default?: { VitePWA?: VitePWAFn } }>(PWA_PACKAGE)
56+
57+
if (!mod?.VitePWA && !mod?.default?.VitePWA) {
58+
await promptForOptionalInstallation(PWA_PACKAGE, 'The "pwa" option')
59+
mod = await importOptionalDependency(PWA_PACKAGE)
60+
}
61+
62+
const VitePWA = mod?.VitePWA ?? mod?.default?.VitePWA
63+
if (!VitePWA)
64+
throw new Error(`[slidev] Failed to load "${PWA_PACKAGE}", which is required by the "pwa" option.`)
65+
66+
return VitePWA
67+
}
68+
69+
/**
70+
* When PWA is disabled, resolve `virtual:pwa-register` to a no-op module. The
71+
* client only imports it behind the `__SLIDEV_FEATURE_PWA__` guard (stripped
72+
* from the build when off), but the guarded dynamic import must still resolve
73+
* during dev — without pulling in the optional `vite-plugin-pwa` dependency.
74+
*/
75+
function createPWARegisterStubPlugin(): Plugin {
76+
const VIRTUAL_ID = 'virtual:pwa-register'
77+
const RESOLVED_ID = `\0${VIRTUAL_ID}`
78+
return {
79+
name: 'slidev:pwa-register-stub',
80+
resolveId(id) {
81+
return id === VIRTUAL_ID ? RESOLVED_ID : undefined
82+
},
83+
load(id) {
84+
if (id === RESOLVED_ID)
85+
return 'export function registerSW() { return () => Promise.resolve() }'
86+
},
87+
}
3888
}

packages/slidev/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,15 @@
4141
"start": "tsx node/cli.ts"
4242
},
4343
"peerDependencies": {
44-
"playwright-chromium": "^1.10.0"
44+
"playwright-chromium": "^1.10.0",
45+
"vite-plugin-pwa": "^1.0.0"
4546
},
4647
"peerDependenciesMeta": {
4748
"playwright-chromium": {
4849
"optional": true
50+
},
51+
"vite-plugin-pwa": {
52+
"optional": true
4953
}
5054
},
5155
"dependencies": {
@@ -115,7 +119,6 @@
115119
"uqr": "catalog:prod",
116120
"vite": "catalog:prod",
117121
"vite-plugin-inspect": "catalog:prod",
118-
"vite-plugin-pwa": "catalog:prod",
119122
"vite-plugin-remote-assets": "catalog:prod",
120123
"vite-plugin-static-copy": "catalog:prod",
121124
"vite-plugin-vue-server-ref": "catalog:prod",

pnpm-lock.yaml

Lines changed: 6 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)