Skip to content

Commit 2cb2dd5

Browse files
committed
offline navigations: cache fallback and current-build assets (4/10)
1 parent 5b339ab commit 2cb2dd5

10 files changed

Lines changed: 609 additions & 20 deletions

File tree

packages/next/src/build/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,10 +231,12 @@ import { generateRoutesManifest } from './generate-routes-manifest'
231231
import { validateAppPaths } from './validate-app-paths'
232232
import {
233233
createOfflineNavigationFallbackDocument,
234+
getOfflineNavigationFallbackDocumentHref,
234235
getOfflineNavigationFallbackFilePath,
235236
} from './offline-navigation-fallback'
236237
import {
237238
createOfflineNavigationServiceWorker,
239+
getOfflineNavigationCacheNamespace,
238240
getOfflineNavigationServiceWorkerFilePath,
239241
} from './offline-navigation-service-worker'
240242

@@ -4122,12 +4124,25 @@ export default async function build(
41224124
distDir,
41234125
getOfflineNavigationServiceWorkerFilePath()
41244126
)
4127+
const cacheNamespace = getOfflineNavigationCacheNamespace({
4128+
basePath: config.basePath,
4129+
buildId,
4130+
})
4131+
const fallbackDocumentHref =
4132+
getOfflineNavigationFallbackDocumentHref({
4133+
basePath: config.basePath,
4134+
buildId,
4135+
})
41254136

41264137
await mkdir(path.dirname(fallbackPath), { recursive: true })
41274138
await writeFileUtf8(fallbackPath, fallbackDocument.html)
41284139
await writeFileUtf8(
41294140
serviceWorkerPath,
4130-
createOfflineNavigationServiceWorker()
4141+
createOfflineNavigationServiceWorker({
4142+
cacheNamespace,
4143+
fallbackAssetHrefs: fallbackDocument.assetHrefs,
4144+
fallbackDocumentHref,
4145+
})
41314146
)
41324147
})
41334148
}

packages/next/src/build/offline-navigation-service-worker.ts

Lines changed: 181 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,199 @@ import path from 'node:path'
22

33
import { CLIENT_STATIC_FILES_PATH } from '../shared/lib/constants'
44
import { OFFLINE_NAVIGATION_SERVICE_WORKER } from '../shared/lib/offline-navigation'
5+
import { OFFLINE_NAVIGATION_CACHE_STATIC_ASSETS } from '../shared/lib/offline-navigation-constants'
56

67
export function getOfflineNavigationServiceWorkerFilePath(): string {
78
return path.join(CLIENT_STATIC_FILES_PATH, OFFLINE_NAVIGATION_SERVICE_WORKER)
89
}
910

11+
export function getOfflineNavigationCacheNamespace({
12+
basePath,
13+
buildId,
14+
}: {
15+
basePath: string
16+
buildId: string
17+
}): string {
18+
return `next-offline-navigation-v1:${buildId}:${basePath || '/'}`
19+
}
20+
1021
function renderServiceWorkerMetadata(metadata: unknown): string {
1122
return `self.__NEXT_OFFLINE_NAVIGATION_SW=${JSON.stringify(metadata)};`
1223
}
1324

14-
const serviceWorkerInstallListener =
15-
"self.addEventListener('install',(event)=>{event.waitUntil(self.skipWaiting())});"
25+
const cachePrefixSource = "const CACHE_PREFIX='next-offline-navigation-v1:';"
26+
27+
const hrefNormalizationSource = [
28+
'function normalizeHref(href){',
29+
'const url=new URL(href,self.location.origin);',
30+
"url.search='';",
31+
"url.hash='';",
32+
'return url.href;',
33+
'}',
34+
'function withDeploymentQuery(href){',
35+
'const url=new URL(normalizeHref(href));',
36+
'const deploymentParams=new URLSearchParams(self.location.search);',
37+
'deploymentParams.forEach((value,key)=>{',
38+
'if(!url.searchParams.has(key)){url.searchParams.set(key,value);}',
39+
'});',
40+
'return url.href;',
41+
'}',
42+
].join('')
43+
44+
const requiredResourceCachingSource = [
45+
'async function fetchRequiredResource(href,withDeploymentId){',
46+
'const normalizedHref=normalizeHref(href);',
47+
'const resourceHref=withDeploymentId?withDeploymentQuery(normalizedHref):normalizedHref;',
48+
'let response;',
49+
"try{response=await fetch(resourceHref,{cache:'no-store'});}",
50+
'catch(err){',
51+
'if(withDeploymentId){throw err;}',
52+
"response=await fetch(resourceHref,{cache:'no-store',mode:'no-cors'});",
53+
'}',
54+
"if(!response.ok&&response.type!=='opaque'){",
55+
"throw new Error('Failed to cache offline navigation resource: '+href);",
56+
'}',
57+
'return response;',
58+
'}',
59+
'async function cacheOfflineNavigationResources(){',
60+
'const metadata=self.__NEXT_OFFLINE_NAVIGATION_SW;',
61+
'const cache=await caches.open(metadata.cacheNamespace);',
62+
'const fallbackResponse=await fetchRequiredResource(metadata.fallbackDocumentHref,true);',
63+
'await cache.put(normalizeHref(metadata.fallbackDocumentHref),fallbackResponse);',
64+
'await Promise.all(metadata.fallbackAssetHrefs.map(async(href)=>{',
65+
'const response=await fetchRequiredResource(href,false);',
66+
'await cache.put(normalizeHref(href),response);',
67+
'}));',
68+
'}',
69+
].join('')
70+
71+
const assetCacheKeySource = [
72+
'function getFallbackAssetCacheKey(request){',
73+
"if(request.method!=='GET'){return null;}",
74+
'const requestHref=normalizeHref(request.url);',
75+
'const metadata=self.__NEXT_OFFLINE_NAVIGATION_SW;',
76+
'return metadata.fallbackAssetHrefs.some((href)=>normalizeHref(href)===requestHref)?requestHref:null;',
77+
'}',
78+
'function getManagedStaticAssetCacheKey(request){',
79+
"if(request.method!=='GET'){return null;}",
80+
'return getManagedStaticAssetCacheKeyFromHref(request.url);',
81+
'}',
82+
'function getManagedStaticAssetCacheKeyFromHref(href){',
83+
'let url;',
84+
'try{url=new URL(href,self.location.origin);}',
85+
'catch(err){return null;}',
86+
"if(!url.pathname.includes('/_next/static/')||url.pathname.includes('/_offline-navigation-')){return null;}",
87+
"url.search='';",
88+
"url.hash='';",
89+
'return url.href;',
90+
'}',
91+
'function getStaticAssetPromotionRequestHref(href){',
92+
'let url;',
93+
'try{url=new URL(href,self.location.origin);}',
94+
'catch(err){return null;}',
95+
'if(url.origin!==self.location.origin||getManagedStaticAssetCacheKeyFromHref(url.href)===null){return null;}',
96+
"url.hash='';",
97+
'return url.href;',
98+
'}',
99+
].join('')
16100

17-
const serviceWorkerActivateListener =
18-
"self.addEventListener('activate',(event)=>{event.waitUntil(self.clients.claim())});"
101+
const staticAssetFetchSource = [
102+
'async function fetchManagedStaticAsset(request){',
103+
'const cacheKey=getFallbackAssetCacheKey(request)||getManagedStaticAssetCacheKey(request);',
104+
'if(cacheKey===null){return fetch(request);}',
105+
'const metadata=self.__NEXT_OFFLINE_NAVIGATION_SW;',
106+
'const cache=await caches.open(metadata.cacheNamespace);',
107+
'const cachedResponse=await cache.match(cacheKey);',
108+
'if(cachedResponse){return cachedResponse;}',
109+
'const response=await fetch(request);',
110+
"if(response.ok||response.type==='opaque'){await cache.put(cacheKey,response.clone());}",
111+
'return response;',
112+
'}',
113+
].join('')
19114

20-
// Generate an app-local service worker for offline navigations. This slice is
21-
// pass-through: it only installs and claims clients so later slices can add
22-
// cache population and fallback document handling.
23-
export function createOfflineNavigationServiceWorker(): string {
24-
const metadata = {
25-
source: 'offline-navigation-service-worker',
26-
}
115+
const currentAssetPromotionSource = [
116+
'async function cacheCurrentStaticAssets(hrefs){',
117+
'if(!Array.isArray(hrefs)||hrefs.length===0){return;}',
118+
'const metadata=self.__NEXT_OFFLINE_NAVIGATION_SW;',
119+
'const cache=await caches.open(metadata.cacheNamespace);',
120+
'await Promise.all(hrefs.slice(0,128).map(async(href)=>{',
121+
"if(typeof href!=='string'||href.length>4096){return;}",
122+
'const cacheKey=getManagedStaticAssetCacheKeyFromHref(href);',
123+
'if(cacheKey===null){return;}',
124+
'const cachedResponse=await cache.match(cacheKey);',
125+
'if(cachedResponse){return;}',
126+
'const requestHref=getStaticAssetPromotionRequestHref(href);',
127+
'if(requestHref===null){return;}',
128+
'let response;',
129+
"try{response=await fetch(requestHref,{cache:'only-if-cached',mode:'same-origin'});}",
130+
'catch(err){return;}',
131+
'if(response.ok){await cache.put(cacheKey,response);}',
132+
'}));',
133+
'}',
134+
].join('')
135+
136+
const installAndActivateListenersSource = [
137+
"self.addEventListener('install',(event)=>{",
138+
'event.waitUntil((async()=>{',
139+
'await cacheOfflineNavigationResources();',
140+
'await self.skipWaiting();',
141+
'})());',
142+
'});',
143+
"self.addEventListener('activate',(event)=>{",
144+
'event.waitUntil((async()=>{',
145+
'const metadata=self.__NEXT_OFFLINE_NAVIGATION_SW;',
146+
'const cacheNames=await caches.keys();',
147+
'await Promise.all(cacheNames.map((cacheName)=>{',
148+
'if(cacheName.startsWith(CACHE_PREFIX)&&cacheName!==metadata.cacheNamespace){',
149+
'return caches.delete(cacheName);',
150+
'}',
151+
'}));',
152+
'await self.clients.claim();',
153+
'})());',
154+
'});',
155+
].join('')
156+
157+
const fetchListenerSource = [
158+
"self.addEventListener('fetch',(event)=>{",
159+
'if(getFallbackAssetCacheKey(event.request)!==null||getManagedStaticAssetCacheKey(event.request)!==null){',
160+
'event.respondWith(fetchManagedStaticAsset(event.request));',
161+
'}',
162+
'});',
163+
].join('')
164+
165+
function renderMessageListener(messageType: string): string {
166+
return `self.addEventListener('message',(event)=>{const data=event.data;if(data&&data.type===${JSON.stringify(
167+
messageType
168+
)}){event.waitUntil(cacheCurrentStaticAssets(data.hrefs));}});`
169+
}
27170

171+
// Offline navigations use a generated, app-local service worker for the
172+
// document fallback and the bootstrap assets referenced by that fallback. It
173+
// does not interpret route data; later client-router slices own route replay.
174+
export function createOfflineNavigationServiceWorker({
175+
cacheNamespace,
176+
fallbackAssetHrefs,
177+
fallbackDocumentHref,
178+
}: {
179+
cacheNamespace: string
180+
fallbackAssetHrefs: string[]
181+
fallbackDocumentHref: string
182+
}): string {
28183
return [
29-
renderServiceWorkerMetadata(metadata),
30-
serviceWorkerInstallListener,
31-
serviceWorkerActivateListener,
184+
renderServiceWorkerMetadata({
185+
cacheNamespace,
186+
fallbackAssetHrefs,
187+
fallbackDocumentHref,
188+
source: 'offline-navigation-service-worker',
189+
}),
190+
cachePrefixSource,
191+
hrefNormalizationSource,
192+
requiredResourceCachingSource,
193+
assetCacheKeySource,
194+
staticAssetFetchSource,
195+
currentAssetPromotionSource,
196+
installAndActivateListenersSource,
197+
fetchListenerSource,
198+
renderMessageListener(OFFLINE_NAVIGATION_CACHE_STATIC_ASSETS),
32199
].join('')
33200
}

packages/next/src/client/offline-navigation-service-worker.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { getDeploymentIdQuery } from '../shared/lib/deployment-id'
22
import { OFFLINE_NAVIGATION_SERVICE_WORKER } from '../shared/lib/offline-navigation'
3+
import { OFFLINE_NAVIGATION_CACHE_STATIC_ASSETS } from '../shared/lib/offline-navigation-constants'
4+
5+
let isSyncingCurrentStaticAssets = false
6+
7+
type OfflineNavigationCacheStaticAssetsMessage = {
8+
type: typeof OFFLINE_NAVIGATION_CACHE_STATIC_ASSETS
9+
hrefs: string[]
10+
}
311

412
function getBasePath(): string {
513
return (process.env.__NEXT_ROUTER_BASEPATH as string) || ''
@@ -14,6 +22,90 @@ function getServiceWorkerScope(): string {
1422
return basePath ? `${basePath}/` : '/'
1523
}
1624

25+
function isNextStaticAssetHref(href: string): boolean {
26+
try {
27+
const url = new URL(href, window.location.href)
28+
return (
29+
url.pathname.includes('/_next/static/') &&
30+
!url.pathname.includes('/_offline-navigation-')
31+
)
32+
} catch {
33+
return false
34+
}
35+
}
36+
37+
function getCurrentNextStaticAssetHrefs(): string[] {
38+
const hrefs = new Set<string>()
39+
40+
for (const element of document.querySelectorAll('script[src],link[href]')) {
41+
let href: string | null = null
42+
if (element instanceof HTMLScriptElement) {
43+
href = element.src
44+
} else if (element instanceof HTMLLinkElement) {
45+
href = element.href
46+
}
47+
48+
if (href !== null && isNextStaticAssetHref(href)) {
49+
hrefs.add(href)
50+
}
51+
}
52+
53+
for (const entry of performance.getEntriesByType('resource')) {
54+
if (isNextStaticAssetHref(entry.name)) {
55+
hrefs.add(entry.name)
56+
}
57+
}
58+
59+
return Array.from(hrefs)
60+
}
61+
62+
function postCurrentNextStaticAssetsToServiceWorker(): void {
63+
const controller = navigator.serviceWorker.controller
64+
if (controller === null) {
65+
return
66+
}
67+
68+
const hrefs = getCurrentNextStaticAssetHrefs()
69+
if (hrefs.length === 0) {
70+
return
71+
}
72+
73+
const message: OfflineNavigationCacheStaticAssetsMessage = {
74+
type: OFFLINE_NAVIGATION_CACHE_STATIC_ASSETS,
75+
hrefs,
76+
}
77+
controller.postMessage(message)
78+
}
79+
80+
function syncCurrentNextStaticAssetsWithServiceWorker(): void {
81+
if (isSyncingCurrentStaticAssets) {
82+
return
83+
}
84+
isSyncingCurrentStaticAssets = true
85+
86+
const post = () => postCurrentNextStaticAssetsToServiceWorker()
87+
88+
// The first page load may fetch bootstrap scripts, CSS, and viewport
89+
// prefetch chunks before the generated worker controls the page. Once it is
90+
// controlling, hand those already-observed static resources to the worker so
91+
// offline replay does not depend on the browser HTTP cache.
92+
if (navigator.serviceWorker.controller !== null) {
93+
post()
94+
} else {
95+
navigator.serviceWorker.addEventListener('controllerchange', post, {
96+
once: true,
97+
})
98+
}
99+
100+
if (document.readyState === 'complete') {
101+
post()
102+
} else {
103+
window.addEventListener('load', post, { once: true })
104+
}
105+
106+
navigator.serviceWorker.ready.then(post).catch(() => {})
107+
}
108+
17109
export function registerOfflineNavigationServiceWorker(): void {
18110
if (
19111
process.env.__NEXT_DEV_SERVER ||
@@ -24,6 +116,8 @@ export function registerOfflineNavigationServiceWorker(): void {
24116
return
25117
}
26118

119+
syncCurrentNextStaticAssetsWithServiceWorker()
120+
27121
// Registration is best-effort: a failed service worker install should not
28122
// affect the current online page load.
29123
navigator.serviceWorker
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const OFFLINE_NAVIGATION_CACHE_STATIC_ASSETS =
2+
'next-offline-navigation-cache-static-assets'
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ReactNode } from 'react'
2+
export default function Root({ children }: { children: ReactNode }) {
3+
return (
4+
<html>
5+
<body>{children}</body>
6+
</html>
7+
)
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use client'
2+
3+
import { useOffline } from 'next/offline'
4+
5+
export function OfflineStatus() {
6+
const isOffline = useOffline()
7+
return <p id="offline-status">{isOffline ? 'offline' : 'online'}</p>
8+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { OfflineStatus } from './offline-status'
2+
3+
export default function Page() {
4+
return (
5+
<>
6+
<p id="offline-navigations-page">offline navigations deploy page</p>
7+
<OfflineStatus />
8+
</>
9+
)
10+
}

0 commit comments

Comments
 (0)