Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add SSR workbox support #66

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 20 additions & 0 deletions playground/error.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { NuxtError } from '#app'

// prevent reactive update when clearing error
const { error } = defineProps<{
error: Partial<NuxtError>
}>()
console.error(error)
</script>

<template>
<div>
<NuxtLayout>
<h1>Error</h1>
<NuxtLink to="/">
Back Home
</NuxtLink>
</NuxtLayout>
</div>
</template>
7 changes: 6 additions & 1 deletion playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export default defineNuxtConfig({
},
pwa: {
registerType: 'autoUpdate',
enableSSR: {
cache: true,
},
manifest: {
name: 'Nuxt Vite PWA',
short_name: 'NuxtVitePWA',
Expand All @@ -48,6 +51,8 @@ export default defineNuxtConfig({
},
workbox: {
navigateFallback: '/',
sourcemap: true,
mode: 'development',
globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
},
client: {
Expand All @@ -57,7 +62,7 @@ export default defineNuxtConfig({
periodicSyncForUpdates: 20,
},
devOptions: {
enabled: true,
enabled: false,
suppressWarnings: true,
navigateFallbackAllowlist: [/^\/$/],
type: 'module',
Expand Down
4 changes: 2 additions & 2 deletions playground/pages/about.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<template>
<div>
<div>Nuxt Vite PWA</div>
<h1>About</h1>
<NuxtLink to="/">
Home 1
Go to Home
</NuxtLink>
</div>
</template>
19 changes: 19 additions & 0 deletions playground/pages/hi/[id].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<template>
<div>
<h3 text-2xl font-500>
Hi,
</h3>
<div text-xl>
<NuxtPage :page-key="page => page.fullPath" />
</div>

<div>
<NuxtLink
class="m-3 text-sm btn"
to="/"
>
Back
</NuxtLink>
</div>
</div>
</template>
19 changes: 19 additions & 0 deletions playground/pages/hi/[id]/detail.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script setup lang="ts">
const route = useRoute<'hi-id-detail'>()
const name = route.params.id ?? ''
if (process.server) {
// eslint-disable-next-line no-console
console.log('Server side', name)
}
</script>

<template>
<div>
<div text-xl>
Detail {{ name }}
</div>
<NuxtLink :to="`/hi/${name}`" class="m-3 text-sm btn">
Back Hi
</NuxtLink>
</div>
</template>
19 changes: 19 additions & 0 deletions playground/pages/hi/[id]/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script setup lang="ts">
const route = useRoute<'hi-id'>()
const name = route.params.id ?? ''
if (process.server) {
// eslint-disable-next-line no-console
console.log('Server side', name)
}
</script>

<template>
<div>
<div text-xl>
{{ name }}!
</div>
<NuxtLink :to="`/hi/${name}/detail`" class="m-3 text-sm btn">
Detail
</NuxtLink>
</div>
</template>
32 changes: 32 additions & 0 deletions playground/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,38 @@
<script setup lang="ts">
const name = ref('')

const router = useRouter()
function go() {
if (name.value)
router.push(`/hi/${encodeURIComponent(name.value)}`)
}
</script>

<template>
<div>
<div>Nuxt Vite PWA</div>
<div>
<input
id="input"
v-model="name"
placeholder="What's your name?"
type="text"
autocomplete="off"
@keydown.enter="go"
>
<div>
<button
:disabled="!name"
@click="go"
>
GO
</button>
</div>
</div>
<NuxtLink to="/list">
Go to list
</NuxtLink>
<br>
<NuxtLink to="/about">
About
</NuxtLink>
Expand Down
9 changes: 9 additions & 0 deletions playground/pages/list.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<template>
<div>
<h1>List</h1>
<NuxtPage />
<NuxtLink to="/">
Go to index
</nuxtlink>
</div>
</template>
20 changes: 20 additions & 0 deletions playground/pages/list/[id]/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
definePageMeta({
key: route => route.fullPath,
})
const route = useRoute<'list-id'>()
const name = route.params.id ?? ''
if (process.server) {
// eslint-disable-next-line no-console
console.log('Server side', name)
}
</script>

<template>
<div>
<h1>Child</h1>
<NuxtLink to="/list">
Back
</NuxtLink>
</div>
</template>
8 changes: 8 additions & 0 deletions playground/pages/list/example.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<template>
<div>
<NuxtPage />
<NuxtLink to="/list">
Back
</NuxtLink>
</div>
</template>
5 changes: 5 additions & 0 deletions playground/pages/list/example/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div>
<h1>Example</h1>
</div>
</template>
8 changes: 8 additions & 0 deletions playground/pages/list/example2.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<template>
<div>
<h1>Example 2</h1>
<NuxtLink to="/list">
Back
</NuxtLink>
</div>
</template>
23 changes: 23 additions & 0 deletions playground/pages/list/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script setup lang="ts">

</script>

<template>
<div>
<NuxtLink to="/list/1">
Go to /list/1
</NuxtLink>
<br>
<NuxtLink to="/list/2">
Go to /list/2
</NuxtLink>
<br>
<NuxtLink to="/list/example">
Go to example
</NuxtLink>
<br>
<NuxtLink to="/list/example2">
Go to example 2
</NuxtLink>
</div>
</template>
130 changes: 128 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { Nuxt } from '@nuxt/schema'
import type { Nuxt, NuxtPage } from '@nuxt/schema'
import { resolve } from 'pathe'
import type { NitroConfig } from 'nitropack'
import type { RuntimeCaching } from 'workbox-build'
import type { ModuleOptions } from './types'

export function configurePWAOptions(options: ModuleOptions, nuxt: Nuxt, nitroConfig: NitroConfig) {
export function configurePWAOptions(
options: ModuleOptions,
nuxt: Nuxt,
nitroConfig: NitroConfig,
ssrPages?: NuxtPage[],
) {
if (!options.outDir) {
const publicDir = nitroConfig.output?.publicDir ?? nuxt.options.nitro?.output?.publicDir
options.outDir = publicDir ? resolve(publicDir) : resolve(nuxt.options.buildDir, '../.output/public')
Expand All @@ -20,6 +26,7 @@ export function configurePWAOptions(options: ModuleOptions, nuxt: Nuxt, nitroCon
>

if (options.strategies === 'injectManifest') {
// TODO: handle enableSSR, we need to inject the routeRules with a custom recipe
options.injectManifest = options.injectManifest ?? {}
config = options.injectManifest
}
Expand All @@ -35,6 +42,40 @@ export function configurePWAOptions(options: ModuleOptions, nuxt: Nuxt, nitroCon
if (options.devOptions?.enabled && !options.devOptions.navigateFallbackAllowlist)
options.devOptions.navigateFallbackAllowlist = [nuxt.options.app.baseURL ? new RegExp(nuxt.options.app.baseURL) : /\//]
}
else if (ssrPages?.length) {
const {
cache = false,
cacheName = 'ssr-pages',
offlinePage = `${nuxt.options.app.baseURL ?? '/'}error?offline`,
} = options.enableSSR!
// 1. filter prerender pages: will go to the sw precache manifest
// what happens if prerender is enabled but prerender routes with ssr?
// don't use nitroConfig, we have _nuxt and __nuxt_error
const prerenderPages = nuxt.options.nitro.prerender?.routes ?? []
const rules = collectRules(ssrPages.filter(p => !prerenderPages.includes(p.path)))
// 2. prepare runtime caching for ssr pages
const routesInfo = Array.from(rules.keys()).map(r => createSSRHandler(r))
// 3. configure workbox properly: since we have two builds, we need to check if previously added
if (routesInfo.length > 0) {
const navigateFallbackDenylist = options.workbox.navigateFallbackDenylist ?? []
const runtimeCaching = options.workbox.runtimeCaching ?? []
routesInfo.forEach((r) => {
const path = r.urlPattern as string
const { exp, dynamic } = rules.get(path)!
const source = exp.source
if (!navigateFallbackDenylist.some(d => d.source === source))
navigateFallbackDenylist.push(exp)

const index = runtimeCaching.findIndex(d => d.urlPattern === path)
if (index > -1)
runtimeCaching[index] = createSSRHandlerFunction(cache, cacheName, offlinePage, exp, dynamic)
else
runtimeCaching.push(r)
})
options.workbox.navigateFallbackDenylist = navigateFallbackDenylist
options.workbox.runtimeCaching = runtimeCaching
}
}
config = options.workbox
}
if (!nuxt.options.dev)
Expand All @@ -59,3 +100,88 @@ function createManifestTransform(base: string): import('workbox-build').Manifest
return { manifest: entries, warnings: [] }
}
}

function createSSRHandler(route: string): RuntimeCaching {
return {
urlPattern: route,
handler: 'NetworkOnly',
}
}

function createSSRHandlerFunction(
cache: boolean,
cacheName: string,
offlinePage: string,
regex: RegExp,
dynamic: boolean,
): RuntimeCaching {
return cache && !dynamic
// eslint-disable-next-line no-eval
? eval(`() => ({
urlPattern: ({ url, sameOrigin }) => sameOrigin && url.pathname.match(${regex}),
handler: 'NetworkFirst',
options: {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add purge on quota error

cacheName: ${JSON.stringify(cacheName)},
cacheableResponse: {
statuses: [200]
},
matchOptions: {
ignoreVary: true,
ignoreSearch: true
},
plugins: [{
handlerDidError: async () => Response.redirect(${JSON.stringify(offlinePage)}, 302),
cacheWillUpdate: async ({ response }) => response.status === 200 ? response : null
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prevent error here (response can be null/undefined when offline)?
cacheWillUpdate: async ({ response }) => response?.status === 200 ? response : null

}]
}
})`)()
// eslint-disable-next-line no-eval
: eval(`() => ({
urlPattern: ({ url, sameOrigin }) => sameOrigin && url.pathname.match(${regex}),
handler: 'NetworkOnly',
options: {
matchOptions: {
ignoreVary: true,
ignoreSearch: true
},
plugins: [{
handlerDidError: async () => Response.redirect(${JSON.stringify(offlinePage)}, 302),
cacheWillUpdate: async () => null
}]
}
})`)()
}

function createRouteRegex(path: string) {
const dynamicRoute = path.indexOf(':')
return dynamicRoute > -1
? { exp: new RegExp(`^${path.slice(0, dynamicRoute)}`), dynamic: true }
: { exp: new RegExp(`^${path}$`), dynamic: false }
}

function traversePage(page: NuxtPage, rules: Map<string, { exp: RegExp; dynamic: boolean }>, parentRoute?: string) {
const path = `${parentRoute ? `${parentRoute}/` : ''}${page.path}`
const route = createRouteRegex(path)
rules.set(path, route)
if (!route.dynamic) {
if (page.children?.length) {
page.children?.filter(p => p.path !== '')
.sort((a, b) => {
if (a.path.startsWith(':'))
return 1

if (b.path.startsWith(':'))
return -1

return b.path.length - a.path.length
})
.forEach(p => traversePage(p, rules, path))
}
}
}

function collectRules(ssrPages: NuxtPage[]) {
const rules = new Map<string, { exp: RegExp; dynamic: boolean }>()
ssrPages.forEach(p => traversePage(p, rules))
return rules
}