Skip to content

Commit 8ac906e

Browse files
committed
offline navigations: register pass-through worker (3/11)
1 parent 6345adc commit 8ac906e

9 files changed

Lines changed: 208 additions & 14 deletions

File tree

packages/next/src/build/index.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,10 @@ import {
237237
createOfflineNavigationManifest,
238238
getOfflineNavigationManifestFilePath,
239239
} from './offline-navigation-manifest'
240+
import {
241+
createOfflineNavigationServiceWorker,
242+
getOfflineNavigationServiceWorkerFilePath,
243+
} from './offline-navigation-service-worker'
240244

241245
type Fallback = null | boolean | string
242246

@@ -4118,17 +4122,29 @@ export default async function build(
41184122
distDir,
41194123
getOfflineNavigationFallbackFilePath(buildId)
41204124
)
4125+
const manifestPath = path.join(
4126+
distDir,
4127+
getOfflineNavigationManifestFilePath(buildId)
4128+
)
4129+
const serviceWorkerPath = path.join(
4130+
distDir,
4131+
getOfflineNavigationServiceWorkerFilePath()
4132+
)
4133+
const manifest = createOfflineNavigationManifest({
4134+
assetPrefix: config.assetPrefix,
4135+
basePath: config.basePath,
4136+
buildId,
4137+
output: config.output,
4138+
trailingSlash: config.trailingSlash,
4139+
})
4140+
41214141
await mkdir(path.dirname(fallbackPath), { recursive: true })
41224142
await writeFileUtf8(fallbackPath, fallbackDocument)
4123-
4124-
await writeManifest(
4125-
path.join(distDir, getOfflineNavigationManifestFilePath(buildId)),
4126-
createOfflineNavigationManifest({
4127-
assetPrefix: config.assetPrefix,
4128-
basePath: config.basePath,
4129-
buildId,
4130-
output: config.output,
4131-
trailingSlash: config.trailingSlash,
4143+
await writeManifest(manifestPath, manifest)
4144+
await writeFileUtf8(
4145+
serviceWorkerPath,
4146+
createOfflineNavigationServiceWorker({
4147+
manifestHref: manifest.manifest.href,
41324148
})
41334149
)
41344150
})

packages/next/src/build/offline-navigation-manifest.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { NextConfigComplete } from '../server/config-shared'
44
import { CLIENT_STATIC_FILES_PATH } from '../shared/lib/constants'
55
import { encodeURIPath } from '../shared/lib/encode-uri-path'
66
import { getOfflineNavigationFallbackFilePath } from './offline-navigation-fallback'
7+
import { getOfflineNavigationServiceWorkerFilePath } from './offline-navigation-service-worker'
78

89
export const OFFLINE_NAVIGATION_MANIFEST = '_offline-navigation-manifest.json'
910

@@ -24,6 +25,10 @@ export interface OfflineNavigationManifest {
2425
path: string
2526
href: string
2627
}
28+
serviceWorker: {
29+
path: string
30+
href: string
31+
}
2732
}
2833

2934
export function getOfflineNavigationManifestFilePath(buildId: string): string {
@@ -60,6 +65,7 @@ export function createOfflineNavigationManifest({
6065
}): OfflineNavigationManifest {
6166
const manifestPath = getOfflineNavigationManifestFilePath(buildId)
6267
const fallbackDocumentPath = getOfflineNavigationFallbackFilePath(buildId)
68+
const serviceWorkerPath = getOfflineNavigationServiceWorkerFilePath()
6369

6470
return {
6571
version: 1,
@@ -80,5 +86,9 @@ export function createOfflineNavigationManifest({
8086
path: fallbackDocumentPath,
8187
href: getStaticHref(basePath, fallbackDocumentPath),
8288
},
89+
serviceWorker: {
90+
path: serviceWorkerPath,
91+
href: getStaticHref(basePath, serviceWorkerPath),
92+
},
8393
}
8494
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import path from 'node:path'
2+
3+
import { CLIENT_STATIC_FILES_PATH } from '../shared/lib/constants'
4+
import { OFFLINE_NAVIGATION_SERVICE_WORKER } from '../shared/lib/offline-navigation'
5+
6+
export function getOfflineNavigationServiceWorkerFilePath(): string {
7+
return path.join(CLIENT_STATIC_FILES_PATH, OFFLINE_NAVIGATION_SERVICE_WORKER)
8+
}
9+
10+
// Generate an app-local service worker for offline navigations. This slice is
11+
// pass-through: it only installs and claims clients so later slices can add
12+
// cache population and fallback document handling.
13+
export function createOfflineNavigationServiceWorker({
14+
manifestHref,
15+
}: {
16+
manifestHref: string
17+
}): string {
18+
const metadata = JSON.stringify({
19+
manifestHref,
20+
source: 'offline-navigation-service-worker',
21+
})
22+
23+
return `self.__NEXT_OFFLINE_NAVIGATION_SW=${metadata};
24+
self.addEventListener('install',(event)=>{event.waitUntil(self.skipWaiting())});
25+
self.addEventListener('activate',(event)=>{event.waitUntil(self.clients.claim())});
26+
`
27+
}

packages/next/src/client/app-index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,16 @@ export async function hydrate(
376376
setNavigationBuildId(getDeploymentId()!)
377377
}
378378

379+
if (process.env.__NEXT_OFFLINE_NAVIGATIONS) {
380+
if (!process.env.__NEXT_DEV_SERVER) {
381+
const { registerOfflineNavigationServiceWorker } =
382+
require('./offline-navigation-service-worker') as typeof import('./offline-navigation-service-worker')
383+
registerOfflineNavigationServiceWorker()
384+
}
385+
} else {
386+
// Keep the service worker module out of disabled client bundles.
387+
}
388+
379389
const initialTimestamp = Date.now()
380390
const actionQueue: AppRouterActionQueue = createMutableActionQueue(
381391
createInitialRouterState({
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { getDeploymentIdQuery } from '../shared/lib/deployment-id'
2+
import { OFFLINE_NAVIGATION_SERVICE_WORKER } from '../shared/lib/offline-navigation'
3+
4+
function getBasePath(): string {
5+
return (process.env.__NEXT_ROUTER_BASEPATH as string) || ''
6+
}
7+
8+
function getServiceWorkerHref(): string {
9+
return `${getBasePath()}/_next/static/${OFFLINE_NAVIGATION_SERVICE_WORKER}${getDeploymentIdQuery()}`
10+
}
11+
12+
function getServiceWorkerScope(): string {
13+
const basePath = getBasePath()
14+
return basePath ? `${basePath}/` : '/'
15+
}
16+
17+
export function registerOfflineNavigationServiceWorker(): void {
18+
if (
19+
process.env.__NEXT_DEV_SERVER ||
20+
typeof window === 'undefined' ||
21+
!window.isSecureContext ||
22+
!('serviceWorker' in navigator)
23+
) {
24+
return
25+
}
26+
27+
// Registration is best-effort: a failed service worker install should not
28+
// affect the current online page load.
29+
navigator.serviceWorker
30+
.register(getServiceWorkerHref(), {
31+
scope: getServiceWorkerScope(),
32+
updateViaCache: 'none',
33+
})
34+
.catch(() => {})
35+
}

packages/next/src/server/lib/router-server.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import loadConfig, { type ConfiguredExperimentalFeature } from '../config'
1313
import { serveStatic } from '../serve-static'
1414
import setupDebug from 'next/dist/compiled/debug'
1515
import * as Log from '../../build/output/log'
16+
import { OFFLINE_NAVIGATION_SERVICE_WORKER } from '../../shared/lib/offline-navigation'
1617
import { DecodeError } from '../../shared/lib/utils'
1718
import { findPagesDir } from '../../lib/find-pages-dir'
1819
import { setupFsCheck } from './router-utils/filesystem'
@@ -524,6 +525,17 @@ export async function initialize(opts: {
524525
}
525526

526527
if (
528+
matchedOutput.type === 'nextStaticFolder' &&
529+
matchedOutput.itemPath.endsWith(
530+
`/${OFFLINE_NAVIGATION_SERVICE_WORKER}`
531+
)
532+
) {
533+
res.setHeader('Cache-Control', 'no-cache, must-revalidate')
534+
res.setHeader(
535+
'Service-Worker-Allowed',
536+
config.basePath ? `${config.basePath}/` : '/'
537+
)
538+
} else if (
527539
!res.getHeader('cache-control') &&
528540
matchedOutput.type === 'nextStaticFolder'
529541
) {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const OFFLINE_NAVIGATION_SERVICE_WORKER =
2+
'_offline-navigation-service-worker.js'

test/production/app-dir/offline-navigations/next.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
const nextConfig = {
55
cacheComponents: true,
6-
assetPrefix: 'https://cdn.example.com/app-assets',
6+
assetPrefix: '/app-assets',
77
basePath: '/docs',
88
experimental: {
99
offlineNavigations: true,

test/production/app-dir/offline-navigations/offline-navigations.test.ts

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync } from 'fs'
22
import { join } from 'path'
33
import { nextTestSetup } from 'e2e-utils'
4+
import { retry } from 'next-test-utils'
45

56
describe('offlineNavigations build artifacts', () => {
67
const { next } = nextTestSetup({
@@ -23,6 +24,15 @@ describe('offlineNavigations build artifacts', () => {
2324
),
2425
relativePath: `.next/static/${buildId}/_offline-navigation-fallback.html`,
2526
},
27+
serviceWorker: {
28+
absolutePath: join(
29+
next.testDir,
30+
'.next',
31+
'static',
32+
'_offline-navigation-service-worker.js'
33+
),
34+
relativePath: `.next/static/_offline-navigation-service-worker.js`,
35+
},
2636
manifest: {
2737
absolutePath: join(
2838
next.testDir,
@@ -40,23 +50,31 @@ describe('offlineNavigations build artifacts', () => {
4050
const buildResult = await next.build()
4151
expect(buildResult.exitCode).toBe(0)
4252

43-
const { buildId, fallbackDocument, manifest } =
53+
const { buildId, fallbackDocument, manifest, serviceWorker } =
4454
await getOfflineNavigationArtifactPaths()
4555
const html = await next.readFile(fallbackDocument.relativePath)
4656
const manifestJson = JSON.parse(await next.readFile(manifest.relativePath))
57+
const serviceWorkerScript = await next.readFile(serviceWorker.relativePath)
4758

4859
expect(html).toContain('data-next-offline-navigation-fallback')
4960
expect(html).toContain('id="__NEXT_OFFLINE_NAVIGATION_FALLBACK"')
5061
expect(html).toContain(`"buildId":"${buildId}"`)
5162
expect(html).toContain('self.__next_f')
52-
expect(html).toContain('https://cdn.example.com/app-assets/_next/static/')
63+
expect(html).toContain('/app-assets/_next/static/')
5364
expect(html).not.toContain('offline navigations page')
5465

66+
expect(serviceWorkerScript).toContain(
67+
`"manifestHref":"/docs/_next/static/${buildId}/_offline-navigation-manifest.json"`
68+
)
69+
expect(serviceWorkerScript).toContain('skipWaiting')
70+
expect(serviceWorkerScript).toContain('clients.claim')
71+
expect(serviceWorkerScript).not.toContain('respondWith')
72+
5573
expect(manifestJson).toEqual({
5674
version: 1,
5775
buildId,
5876
basePath: '/docs',
59-
assetPrefix: 'https://cdn.example.com/app-assets',
77+
assetPrefix: '/app-assets',
6078
trailingSlash: true,
6179
output: 'default',
6280
scope: '/docs/',
@@ -69,9 +87,72 @@ describe('offlineNavigations build artifacts', () => {
6987
path: `static/${buildId}/_offline-navigation-fallback.html`,
7088
href: `/docs/_next/static/${buildId}/_offline-navigation-fallback.html`,
7189
},
90+
serviceWorker: {
91+
path: `static/_offline-navigation-service-worker.js`,
92+
href: `/docs/_next/static/_offline-navigation-service-worker.js`,
93+
},
7294
})
7395
})
7496

97+
it('registers the pass-through service worker when enabled', async () => {
98+
const buildResult = await next.build()
99+
expect(buildResult.exitCode).toBe(0)
100+
101+
await next.start({ skipBuild: true })
102+
103+
try {
104+
const swResponse = await next.fetch(
105+
`/docs/_next/static/_offline-navigation-service-worker.js${next.getDeploymentIdQuery()}`
106+
)
107+
expect(swResponse.status).toBe(200)
108+
expect(swResponse.headers.get('cache-control')).toBe(
109+
'no-cache, must-revalidate'
110+
)
111+
expect(swResponse.headers.get('service-worker-allowed')).toBe('/docs/')
112+
113+
const browser = await next.browser('/docs')
114+
await retry(async () => {
115+
const registration = await browser.eval(async () => {
116+
if (!('serviceWorker' in navigator)) {
117+
return null
118+
}
119+
120+
const registrations = await navigator.serviceWorker.getRegistrations()
121+
const registration = registrations.find((registration) =>
122+
registration.scope.endsWith('/docs/')
123+
)
124+
125+
if (!registration) {
126+
return null
127+
}
128+
129+
return {
130+
scope: registration.scope,
131+
scriptURL: registration.active?.scriptURL ?? null,
132+
}
133+
})
134+
135+
expect(registration).toEqual({
136+
scope: `${next.url}/docs/`,
137+
scriptURL: `${next.url}/docs/_next/static/_offline-navigation-service-worker.js${next.getDeploymentIdQuery()}`,
138+
})
139+
})
140+
141+
await browser.eval(async () => {
142+
if (!('serviceWorker' in navigator)) {
143+
return
144+
}
145+
146+
const registrations = await navigator.serviceWorker.getRegistrations()
147+
await Promise.all(
148+
registrations.map((registration) => registration.unregister())
149+
)
150+
})
151+
} finally {
152+
await next.stop()
153+
}
154+
})
155+
75156
it('does not emit offline navigation artifacts when disabled', async () => {
76157
await next.patchFile('next.config.js', (content) =>
77158
content.replace('offlineNavigations: true', 'offlineNavigations: false')
@@ -80,9 +161,10 @@ describe('offlineNavigations build artifacts', () => {
80161
const buildResult = await next.build()
81162
expect(buildResult.exitCode).toBe(0)
82163

83-
const { fallbackDocument, manifest } =
164+
const { fallbackDocument, manifest, serviceWorker } =
84165
await getOfflineNavigationArtifactPaths()
85166
expect(existsSync(fallbackDocument.absolutePath)).toBe(false)
86167
expect(existsSync(manifest.absolutePath)).toBe(false)
168+
expect(existsSync(serviceWorker.absolutePath)).toBe(false)
87169
})
88170
})

0 commit comments

Comments
 (0)