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(experimental): add includeAllowlist for generateSW strategy #108

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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions client-test/offline.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import process from 'node:process'
import { expect, test } from '@playwright/test'

const build = process.env.TEST_BUILD === 'true'
const allowlist = process.env.ALLOW_LIST === 'true'

test('Test offline', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
page.on('console', (msg) => {
if (msg.type() === 'error')
// eslint-disable-next-line no-console
console.log(`Error text: "${msg.text()}"`)
})
await page.goto('/')

const swURL = await page.evaluate(async () => {
const registration = await Promise.race([
navigator.serviceWorker.ready,
new Promise((_resolve, reject) => setTimeout(() => reject(new Error('Service worker registration failed: time out')), 10000)),
])
// @ts-expect-error TS18046: 'registration' is of type 'unknown'.
return registration.active?.scriptURL
})
const swName = 'sw.js'
expect(swURL).toBe(`http://localhost:4173/${swName}`)

await new Promise(resolve => setTimeout(resolve, 3000))

// TODO: PW seems to be not working properly
if (true)
return

await context.setOffline(true)

// test missing page
if (allowlist) {
if (build) {
// TODO: test runtime caching
}
else {
await page.goto('/missing')
await page.reload({ waitUntil: 'load' })
const url = await page.evaluate(() => location.href)
expect(url).toBe('http://localhost:4173/404')
await expect(page.getByText('404')).toBeVisible()
await expect(page.getByText('Page not found: /404')).toBeVisible()
}
}
})
19 changes: 18 additions & 1 deletion client-test/sw.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import process from 'node:process'
import { expect, test } from '@playwright/test'

test('The service worker is registered and cache storage is present', async ({ page }) => {
const allowlist = process.env.ALLOW_LIST === 'true'

test('The service worker is registered and cache storage is present', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/')

const swURL = await page.evaluate(async () => {
Expand Down Expand Up @@ -44,4 +49,16 @@ test('The service worker is registered and cache storage is present', async ({ p
expect(urls.some(url => url.startsWith('_nuxt/') && url.endsWith('.js'))).toEqual(true)
expect(urls.some(url => url.includes('_payload.json?__WB_REVISION__='))).toEqual(true)
expect(urls.some(url => url.startsWith('_nuxt/builds/') && url.includes('.json'))).toEqual(true)
expect(urls.some(url => url.includes('_nuxt/builds/latest.json?__WB_REVISION__='))).toEqual(true)
// test missing page
if (allowlist) {
await page.goto('/missing')
const url = await page.evaluate(async () => {
await new Promise(resolve => setTimeout(resolve, 3000))
return location.href
})
expect(url).toBe('http://localhost:4173/missing')
await expect(page.getByText('404')).toBeVisible()
await expect(page.getByText('Page not found: /missing')).toBeVisible()
}
})
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@
"prepack": "nuxt-module-build prepare && nuxt-module-build build",
"dev": "nuxi dev playground",
"dev:generate": "nuxi generate playground",
"dev:generate:allowlist": "ALLOW_LIST=true nuxi generate playground",
"dev:generate:netlify": "NITRO_PRESET=netlify nuxi generate playground",
"dev:generate:vercel": "NITRO_PRESET=vercel nuxi generate playground",
"dev:build": "nuxi build playground",
"dev:build": "TEST_BUILD=true nuxi build playground",
"dev:build:allowlist": "TEST_BUILD=true ALLOW_LIST=true nuxi build playground",
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
"dev:preview:build": "nr dev:build && node playground/.output/server/index.mjs",
"dev:preview:generate": "nr dev:generate && serve playground/dist",
Expand All @@ -52,8 +54,10 @@
"test:build:serve": "PORT=4173 node playground/.output/server/index.mjs",
"test:generate:serve": "PORT=4173 serve playground/dist",
"test:build": "nr dev:build && TEST_BUILD=true vitest run && TEST_BUILD=true playwright test",
"test:build:allowlist": "nr dev:build:allowlist && ALLOW_LIST=true TEST_BUILD=true vitest run && ALLOW_LIST=true TEST_BUILD=true playwright test",
"test:generate": "nr dev:generate && vitest run && playwright test",
"test": "nr test:build && nr test:generate",
"test:generate:allowlist": "nr dev:generate:allowlist && ALLOW_LIST=true vitest run && ALLOW_LIST=true playwright test",
"test": "nr test:build && nr test:build:allowlist && nr test:generate && nr test:generate:allowlist",
"test:with-build": "nr dev:prepare && nr prepack && nr test"
},
"dependencies": {
Expand Down
31 changes: 31 additions & 0 deletions playground/components/InputEntry.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script setup lang="ts">
const name = ref('')

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

<template>
<div>
<input
id="input"
v-model="name"
placeholder="What's your name?"
type="text"
autocomplete="off"
@keydown.enter="go"
>
<div>
<button
m-3 text-sm btn
:disabled="!name"
@click="go"
>
GO
</button>
</div>
</div>
</template>
17 changes: 15 additions & 2 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import process from 'node:process'

const build = process.env.TEST_BUILD === 'true'
const allowList = process.env.ALLOW_LIST === 'true'

export default defineNuxtConfig({
/* ssr: false, */
ssr: true,
modules: ['@vite-pwa/nuxt'],
future: {
typescriptBundlerResolution: true,
Expand All @@ -26,6 +31,7 @@ export default defineNuxtConfig({
buildDate: new Date().toISOString(),
},
pwa: {
mode: 'development',
registerType: 'autoUpdate',
manifest: {
name: 'Nuxt Vite PWA',
Expand Down Expand Up @@ -59,8 +65,15 @@ export default defineNuxtConfig({
// if enabling periodic sync for update use 1 hour or so (periodicSyncForUpdates: 3600)
periodicSyncForUpdates: 20,
},
experimental: allowList
? {
includeAllowlist: {
redirectPage: build ? '/' : '404',
},
}
: undefined,
devOptions: {
enabled: true,
enabled: false,
suppressWarnings: true,
navigateFallbackAllowlist: [/^\/$/],
type: 'module',
Expand Down
12 changes: 12 additions & 0 deletions playground/pages/hi/[id].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script setup lang="ts">
const route = useRoute<'hi-id'>()
</script>

<template>
<div>
<h1>Hi {{ route.params.id }}</h1>
<NuxtLink to="/">
Back
</NuxtLink>
</div>
</template>
27 changes: 27 additions & 0 deletions playground/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
<script setup lang="ts">
const online = ref(false)

onBeforeMount(() => {
online.value = navigator.onLine
window.addEventListener('online', () => {
online.value = true
})
window.addEventListener('offline', () => {
online.value = false
})
})
</script>

<template>
<div>
<h1>Nuxt Vite PWA</h1>
<div>
PWA Installed: {{ $pwa?.isPWAInstalled }}
</div>
<Suspense>
<ClientOnly>
<div v-if="!online">
You're offline
</div>
</ClientOnly>
<template #fallback>
<div italic op50>
<span animate-pulse>Loading...</span>
</div>
</template>
</Suspense>
<NuxtLink to="/about">
About
</NuxtLink>
<InputEntry />
</div>
</template>
42 changes: 42 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Nuxt } from '@nuxt/schema'
import { resolve } from 'pathe'
import type { NitroConfig } from 'nitropack'
import type { PwaModuleOptions } from './types'
import { escapeStringRegexp } from './utils'

export function configurePWAOptions(
nuxt3_8: boolean,
Expand Down Expand Up @@ -73,6 +74,47 @@ export function configurePWAOptions(
}
}

if (!nuxt.options.dev && options.strategies !== 'injectManifest' && options.experimental?.includeAllowlist) {
let fallback = typeof options.experimental?.includeAllowlist === 'object' ? options.experimental.includeAllowlist.redirectPage ?? '404' : '404'
fallback = fallback.startsWith('/') ? fallback : `${options.base ?? '/'}${fallback}`

const pagesExtends: string[] = []
const pages = new Promise<string[]>((resolve) => {
resolve(pagesExtends)
})

nuxt.hook('prerender:routes', ({ routes }) => {
if (!nuxt.options._generate && !routes.has(fallback))
throw new Error(`You are running "build" command and the redirect page for experimental "includeAllowlist" not being prerendered: ${fallback}`)

pagesExtends.push(...routes)
})

options.integration = {
async beforeBuildServiceWorker(resolved) {
const routes = await pages
if (!routes.length)
return

resolved.workbox.navigateFallbackAllowlist = resolved.workbox.navigateFallbackAllowlist ?? []
resolved.workbox.navigateFallbackAllowlist.push(...routes.map(r => new RegExp(`^${escapeStringRegexp(r)}$`)))
resolved.workbox.runtimeCaching = resolved.workbox.runtimeCaching ?? []
const { createContext, runInContext } = await import('node:vm')
const context = createContext()
resolved.workbox.runtimeCaching.push(runInContext(`() => ({
urlPattern: ({ request, sameOrigin }) => sameOrigin && request.mode === 'navigate',
handler: 'NetworkOnly',
options: {
plugins: [{
handlerDidError: async () => Response.redirect(${JSON.stringify(fallback)}, 302),
cacheWillUpdate: async () => null
}]
}
})`, context)())
},
}
}

// handle Nuxt App Manifest
let appManifestFolder: string | undefined
if (nuxt3_8 && nuxt.options.experimental.appManifest) {
Expand Down
24 changes: 24 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export interface ClientOptions {
installPrompt?: boolean | string
}

export interface AllowListOptions {
/**
* The redirection page when the route is not found.
*
* @default '404'
*/
redirectPage?: string
}

export interface PwaModuleOptions extends Partial<VitePWAOptions> {
registerWebManifestInRouteRules?: boolean
/**
Expand All @@ -31,4 +40,19 @@ export interface PwaModuleOptions extends Partial<VitePWAOptions> {
* Options for plugin.
*/
client?: ClientOptions
/**
* Experimental options.
*/
experimental?: {
/**
* Only for `generateSW` strategy, include the logic to handle the `workbox.navigateFallbackAllowlist` option.
*
* When using `true`, this module will include a Workbox runtime caching for all dynamic and missing routes using `NetworkOnly` strategy via `404` redirection.
*
* You can create a custom page to replace `404` using the `redirectPage` option, remember the page **MUST** be prerenderer, cannot be dynamic or SSR page.
*
* @default false
*/
includeAllowlist?: boolean | AllowListOptions
}
}
8 changes: 8 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { writeFile } from 'node:fs/promises'
import type { VitePluginPWAAPI } from 'vite-plugin-pwa'
import { resolve } from 'pathe'

export function escapeStringRegexp(value: string) {
// Escape characters with special meaning either inside or outside character sets.
// Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar.
return value
.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
.replace(/-/g, '\\x2d')
}

export async function regeneratePWA(_dir: string, api?: VitePluginPWAAPI) {
if (!api || api.disabled)
return
Expand Down
27 changes: 19 additions & 8 deletions test/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import process from 'node:process'
import { describe, expect, it } from 'vitest'

const build = process.env.TEST_BUILD === 'true'
const allowList = process.env.ALLOW_LIST === 'true'

describe(`test-${build ? 'build' : 'generate'}`, () => {
it('service worker is generated: ', () => {
Expand All @@ -16,24 +17,34 @@ describe(`test-${build ? 'build' : 'generate'}`, () => {
expect(existsSync(webManifest), `${webManifest} doesn't exist`).toBeTruthy()
const swContent = readFileSync(swName, 'utf-8')
let match: RegExpMatchArray | null
match = swContent.match(/define\(\["\.\/(workbox-\w+)"/)
match = swContent.match(/define\(\[['"]\.\/(workbox-\w+)['"]/)
expect(match && match.length === 2, `workbox-***.js entry not found in ${swName}`).toBeTruthy()
const workboxName = `./playground/${build ? '.output/public' : 'dist'}/${match?.[1]}.js`
expect(existsSync(workboxName), `${workboxName} doesn't exist`).toBeTruthy()
match = swContent.match(/url:\s*"manifest\.webmanifest"/)
match = swContent.match(/['"]?url['"]?:\s*['"]manifest\.webmanifest['"]/)
expect(match && match.length === 1, 'missing manifest.webmanifest in sw precache manifest').toBeTruthy()
match = swContent.match(/url:\s*"\/"/)
match = swContent.match(/['"]?url['"]?:\s*"\/"/)
expect(match && match.length === 1, 'missing entry point route (/) in sw precache manifest').toBeTruthy()
match = swContent.match(/url:\s*"about"/)
match = swContent.match(/['"]?url['"]?:\s*['"]about['"]/)
expect(match && match.length === 1, 'missing about route (/about) in sw precache manifest').toBeTruthy()
match = swContent.match(/url:\s*"_nuxt\/.*\.(css|js)"/)
match = swContent.match(/['"]?url['"]?:\s*['"]_nuxt\/.*\.(css|js)['"]/)
expect(match && match.length > 0, 'missing _nuxt/**.(css|js) in sw precache manifest').toBeTruthy()
match = swContent.match(/url:\s*"(.*\/)?_payload.json"/)
match = swContent.match(/['"]?url['"]?:\s*"(.*\/)?_payload.json"/)
expect(match && match.length === 2, 'missing _payload.json and about/_payload.json entries in sw precache manifest').toBeTruthy()
match = swContent.match(/url:\s*"_nuxt\/builds\/.*\.json"/)
match = swContent.match(/['"]?url['"]?:\s*['"]_nuxt\/builds\/.*\.json['"]/)
expect(match && match.length > 0, 'missing App Manifest json entries in sw precache manifest').toBeTruthy()
if (allowList) {
if (build) {
match = swContent.match(/Response\.redirect\(['"]\/['"], 302\)/)
expect(match && match.length > 0, 'missing runtime caching entry for /').toBeTruthy()
}
else {
match = swContent.match(/Response\.redirect\(['"]\/404['"], 302\)/)
expect(match && match.length > 0, 'missing runtime caching entry for /404').toBeTruthy()
}
}
if (build) {
match = swContent.match(/url:\s*"server\//)
match = swContent.match(/['"]?url['"]?:\s*['"]server\//)
expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy()
}
})
Expand Down