Skip to content
Closed
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
3 changes: 2 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -1190,5 +1190,6 @@
"1189": "Route \"%s\" accessed header \"%s\" which is not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`headers\\` array, or \\`[\"%s\", null]\\` if it should be absent.",
"1190": "Route \"%s\" accessed param \"%s\" which is not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`params\\` object.",
"1191": "Route \"%s\" called %s but param%s %s %s not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. %s requires all route params to be provided.",
"1192": "Route \"%s\" accessed root param \"%s\" which is not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`params\\` object."
"1192": "Route \"%s\" accessed root param \"%s\" which is not defined in the \\`unstable_samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`params\\` object.",
"1193": "\\`experimental.offlineNavigations\\` requires \\`cacheComponents\\` to be enabled. Please update your %s accordingly."
}
7 changes: 6 additions & 1 deletion packages/next/src/build/define-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,12 @@ export function getDefineEnv({
'process.env.__NEXT_DYNAMIC_ON_HOVER': Boolean(
config.experimental.dynamicOnHover
),
'process.env.__NEXT_USE_OFFLINE': Boolean(config.experimental.useOffline),
'process.env.__NEXT_OFFLINE_NAVIGATIONS': Boolean(
config.experimental.offlineNavigations
),
'process.env.__NEXT_USE_OFFLINE': Boolean(
config.experimental.useOffline || config.experimental.offlineNavigations
),
'process.env.__NEXT_PREFETCH_INLINING': Boolean(
config.experimental.prefetchInlining
),
Expand Down
63 changes: 63 additions & 0 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,18 @@ import {
} from '../server/lib/router-utils/build-prefetch-segment-data-route'
import { generateRoutesManifest } from './generate-routes-manifest'
import { validateAppPaths } from './validate-app-paths'
import {
createOfflineNavigationFallbackDocument,
getOfflineNavigationFallbackFilePath,
} from './offline-navigation-fallback'
import {
createOfflineNavigationManifest,
getOfflineNavigationManifestFilePath,
} from './offline-navigation-manifest'
import {
createOfflineNavigationServiceWorker,
getOfflineNavigationServiceWorkerFilePath,
} from './offline-navigation-service-worker'

type Fallback = null | boolean | string

Expand Down Expand Up @@ -4091,6 +4103,57 @@ export default async function build(

// #endregion

if (config.experimental.offlineNavigations && appDir) {
await nextBuildSpan
.traceChild('write-offline-navigation-artifacts')
.traceAsyncFn(async () => {
const fallbackDocument = createOfflineNavigationFallbackDocument({
assetPrefix: config.assetPrefix,
buildId,
buildManifest,
crossOrigin: config.crossOrigin,
deploymentId: config.deploymentId,
})

if (fallbackDocument === null) {
return
}

const fallbackPath = path.join(
distDir,
getOfflineNavigationFallbackFilePath(buildId)
)
const manifestPath = path.join(
distDir,
getOfflineNavigationManifestFilePath(buildId)
)
const serviceWorkerPath = path.join(
distDir,
getOfflineNavigationServiceWorkerFilePath()
)
const manifest = createOfflineNavigationManifest({
assetPrefix: config.assetPrefix,
basePath: config.basePath,
buildId,
output: config.output,
trailingSlash: config.trailingSlash,
})

await mkdir(path.dirname(fallbackPath), { recursive: true })
await writeFileUtf8(fallbackPath, fallbackDocument)
await writeManifest(manifestPath, manifest)
await writeFileUtf8(
serviceWorkerPath,
createOfflineNavigationServiceWorker({
buildId,
cacheNamespace: manifest.cacheNamespace,
fallbackDocumentHref: manifest.fallbackDocument.href,
manifestHref: manifest.manifest.href,
})
)
})
}

await writeImagesManifest(distDir, config)
await writeManifest(path.join(distDir, EXPORT_MARKER), {
version: 1,
Expand Down
90 changes: 90 additions & 0 deletions packages/next/src/build/offline-navigation-fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import path from 'node:path'

import type { BuildManifest } from '../server/get-page-files'
import { encodeURIPath } from '../shared/lib/encode-uri-path'
import {
htmlEscapeAttributeString,
htmlEscapeJsonString,
} from '../shared/lib/htmlescape'
import { CLIENT_STATIC_FILES_PATH } from '../shared/lib/constants'

export const OFFLINE_NAVIGATION_FALLBACK_HTML =
'_offline-navigation-fallback.html'

export function getOfflineNavigationFallbackFilePath(buildId: string): string {
return path.join(
CLIENT_STATIC_FILES_PATH,
buildId,
OFFLINE_NAVIGATION_FALLBACK_HTML
)
}

function getAssetHref(assetPrefix: string, file: string): string {
return `${assetPrefix}/_next/${encodeURIPath(file)}`
}

function getScriptAttributes(
crossOrigin: '' | 'anonymous' | 'use-credentials' | undefined
): string {
if (!crossOrigin) {
return ''
}

return ` crossOrigin="${htmlEscapeAttributeString(crossOrigin)}"`
}

export function createOfflineNavigationFallbackDocument({
assetPrefix,
buildId,
buildManifest,
crossOrigin,
deploymentId,
}: {
assetPrefix: string
buildId: string
buildManifest: BuildManifest
crossOrigin: '' | 'anonymous' | 'use-credentials' | undefined
deploymentId: string | undefined
}): string | null {
const rootMainFiles = buildManifest.rootMainFiles.filter((file) =>
file.endsWith('.js')
)

if (rootMainFiles.length === 0) {
return null
}

const polyfillScripts = buildManifest.polyfillFiles
.filter((file) => file.endsWith('.js') && !file.endsWith('.module.js'))
.map((file) => {
return `<script src="${htmlEscapeAttributeString(
getAssetHref(assetPrefix, file)
)}" noModule${getScriptAttributes(crossOrigin)}></script>`
})
.join('')

const bootstrapScripts = rootMainFiles
.map((file) => {
return `<script src="${htmlEscapeAttributeString(
getAssetHref(assetPrefix, file)
)}" async${getScriptAttributes(crossOrigin)}></script>`
})
.join('')

const metadata = {
buildId,
source: 'offline-navigation-fallback',
}

const deploymentIdAttribute = deploymentId
? ` data-dpl-id="${htmlEscapeAttributeString(deploymentId)}"`
: ''

return `<!DOCTYPE html><html data-next-offline-navigation-fallback="" data-build-id="${htmlEscapeAttributeString(
buildId
)}"${deploymentIdAttribute}><head><meta charSet="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="next-offline-navigation-fallback" content="1"><meta name="next-build-id" content="${htmlEscapeAttributeString(
buildId
)}"><script id="__NEXT_OFFLINE_NAVIGATION_FALLBACK" type="application/json">${htmlEscapeJsonString(
JSON.stringify(metadata)
)}</script></head><body><div id="__next"></div><p id="__NEXT_OFFLINE_NAVIGATION_CACHE_MISS" hidden>This page is not available offline.</p><script>self.__next_f=self.__next_f||[];self.__next_f.push([0])</script>${polyfillScripts}${bootstrapScripts}</body></html>`
}
89 changes: 89 additions & 0 deletions packages/next/src/build/offline-navigation-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import path from 'node:path'

import type { NextConfigComplete } from '../server/config-shared'
import { CLIENT_STATIC_FILES_PATH } from '../shared/lib/constants'
import { encodeURIPath } from '../shared/lib/encode-uri-path'
import { getOfflineNavigationFallbackFilePath } from './offline-navigation-fallback'
import { getOfflineNavigationServiceWorkerFilePath } from './offline-navigation-service-worker'

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

export interface OfflineNavigationManifest {
version: 1
buildId: string
basePath: string
assetPrefix: string
trailingSlash: boolean
output: NonNullable<NextConfigComplete['output']> | 'default'
scope: string
cacheNamespace: string
manifest: {
path: string
href: string
}
fallbackDocument: {
path: string
href: string
}
serviceWorker: {
path: string
href: string
}
}

export function getOfflineNavigationManifestFilePath(buildId: string): string {
return path.join(
CLIENT_STATIC_FILES_PATH,
buildId,
OFFLINE_NAVIGATION_MANIFEST
)
}

function getStaticHref(basePath: string, filePath: string): string {
return `${basePath}/_next/${encodeURIPath(filePath)}`
}

function getScope(basePath: string): string {
return basePath ? `${basePath}/` : '/'
}

export function createOfflineNavigationManifest({
assetPrefix,
basePath,
buildId,
output,
trailingSlash,
}: {
assetPrefix: string
basePath: string
buildId: string
output: NextConfigComplete['output']
trailingSlash: boolean
}): OfflineNavigationManifest {
const manifestPath = getOfflineNavigationManifestFilePath(buildId)
const fallbackDocumentPath = getOfflineNavigationFallbackFilePath(buildId)
const serviceWorkerPath = getOfflineNavigationServiceWorkerFilePath()

return {
version: 1,
buildId,
basePath,
assetPrefix,
trailingSlash,
output: output ?? 'default',
scope: getScope(basePath),
cacheNamespace: `next-offline-navigation-v1:${buildId}:${basePath || '/'}`,
manifest: {
path: manifestPath,
href: getStaticHref(basePath, manifestPath),
},
fallbackDocument: {
path: fallbackDocumentPath,
href: getStaticHref(basePath, fallbackDocumentPath),
},
serviceWorker: {
path: serviceWorkerPath,
href: getStaticHref(basePath, serviceWorkerPath),
},
}
}
115 changes: 115 additions & 0 deletions packages/next/src/build/offline-navigation-service-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import path from 'node:path'

import { CLIENT_STATIC_FILES_PATH } from '../shared/lib/constants'
import { OFFLINE_NAVIGATION_SERVICE_WORKER } from '../shared/lib/offline-navigation'
import { OFFLINE_NAVIGATION_FALLBACK_SERVED } from '../shared/lib/offline-navigation-constants'

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

export function createOfflineNavigationServiceWorker({
buildId,
cacheNamespace,
fallbackDocumentHref,
manifestHref,
}: {
buildId: string
cacheNamespace: string
fallbackDocumentHref: string
manifestHref: string
}): string {
const metadata = JSON.stringify({
buildId,
cacheNamespace,
fallbackDocumentHref,
manifestHref,
source: 'offline-navigation-service-worker',
})
const fallbackServedMessageType = JSON.stringify(
OFFLINE_NAVIGATION_FALLBACK_SERVED
)

return `self.__NEXT_OFFLINE_NAVIGATION_SW=${metadata};
const CACHE_PREFIX='next-offline-navigation-v1:';
function withDeploymentQuery(href){
const url=new URL(href,self.location.origin);
const deploymentParams=new URLSearchParams(self.location.search);
deploymentParams.forEach((value,key)=>{
if(!url.searchParams.has(key)){
url.searchParams.set(key,value);
}
});
return url.href;
}
async function fetchRequiredResource(href){
const response=await fetch(withDeploymentQuery(href),{cache:'no-store'});
if(!response.ok){
throw new Error('Failed to cache offline navigation resource: '+href);
}
return response;
}
async function cacheOfflineNavigationResources(){
const metadata=self.__NEXT_OFFLINE_NAVIGATION_SW;
const cache=await caches.open(metadata.cacheNamespace);
const manifestResponse=await fetchRequiredResource(metadata.manifestHref);
const manifest=await manifestResponse.clone().json();
await cache.put(metadata.manifestHref,manifestResponse);
const fallbackResponse=await fetchRequiredResource(manifest.fallbackDocument.href);
await cache.put(manifest.fallbackDocument.href,fallbackResponse);
}
function isDocumentNavigationRequest(request){
if(request.method!=='GET'||request.mode!=='navigate'||request.destination!=='document'){
return false;
}
const url=new URL(request.url);
return url.origin===self.location.origin;
}
async function fetchDocumentNavigation(request){
try{
return await fetch(request);
}catch(err){
const metadata=self.__NEXT_OFFLINE_NAVIGATION_SW;
const cache=await caches.open(metadata.cacheNamespace);
const fallbackResponse=await cache.match(metadata.fallbackDocumentHref);
if(fallbackResponse){
await notifyClients({
type:${fallbackServedMessageType},
buildId:metadata.buildId,
reason:'network-error',
url:request.url
});
return fallbackResponse;
}
throw err;
}
}
async function notifyClients(message){
const clients=await self.clients.matchAll({type:'window',includeUncontrolled:true});
await Promise.all(clients.map((client)=>client.postMessage(message)));
}
self.addEventListener('install',(event)=>{
event.waitUntil((async()=>{
await cacheOfflineNavigationResources();
await self.skipWaiting();
})());
});
self.addEventListener('activate',(event)=>{
event.waitUntil((async()=>{
const metadata=self.__NEXT_OFFLINE_NAVIGATION_SW;
const cacheNames=await caches.keys();
await Promise.all(cacheNames.map((cacheName)=>{
if(cacheName.startsWith(CACHE_PREFIX)&&cacheName!==metadata.cacheNamespace){
return caches.delete(cacheName);
}
}));
await self.clients.claim();
})());
});
self.addEventListener('fetch',(event)=>{
if(isDocumentNavigationRequest(event.request)){
event.respondWith(fetchDocumentNavigation(event.request));
}
});
`
}
Loading
Loading