Skip to content

Commit b7f59e7

Browse files
committed
Cache static assets in service worker
1 parent 17aada6 commit b7f59e7

File tree

2 files changed

+109
-14
lines changed

2 files changed

+109
-14
lines changed

next.config.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const { withPlausibleProxy } = require('next-plausible')
22
const { InjectManifest } = require('workbox-webpack-plugin')
3-
const { generatePrecacheManifest } = require('./sw/build.js')
3+
const { generateDummyPrecacheManifest, addStaticAssetsInServiceWorker } = require('./sw/build.js')
44
const webpack = require('webpack')
55

66
let isProd = process.env.NODE_ENV === 'production'
@@ -214,7 +214,7 @@ module.exports = withPlausibleProxy()({
214214
},
215215
webpack: (config, { isServer, dev, defaultLoaders }) => {
216216
if (isServer) {
217-
generatePrecacheManifest()
217+
generateDummyPrecacheManifest()
218218
const workboxPlugin = new InjectManifest({
219219
// ignore the precached manifest which includes the webpack assets
220220
// since they are not useful to us
@@ -258,6 +258,25 @@ module.exports = withPlausibleProxy()({
258258

259259
config.plugins.push(workboxPlugin)
260260
}
261+
// Static assets cannot be added in server build they are emitted in the client build phase https://nextjs.org/docs/14/app/api-reference/next-config-js/webpack
262+
// and are not needed in dev
263+
// The service worker ONLY includes the assets in precache-manifest.json injected by Workbox plugin when the build starts
264+
// but our static assets only exist later in the build.
265+
// However the precache-manifest.json assets are hardcoded in an array in the generated service worker by Worbox in public/sw.js
266+
// So we patch it to include our own custom assets
267+
268+
if (!dev) {
269+
config.plugins.push({
270+
apply: (compiler) => {
271+
compiler.hooks.afterEmit.tapPromise(
272+
'AddEmittedStaticAssetUrlsToServiceWorker',
273+
async () => {
274+
await addStaticAssetsInServiceWorker()
275+
}
276+
)
277+
}
278+
})
279+
}
261280

262281
config.module.rules.push(
263282
{

sw/build.js

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const { createHash } = require('crypto')
2-
const { readdirSync, readFileSync, statSync, writeFileSync } = require('fs')
3-
const { join } = require('path')
2+
const { readdirSync, readFileSync, statSync, writeFileSync, existsSync } = require('fs')
3+
const { join, resolve } = require('path')
44

55
const getRevision = filePath => createHash('md5').update(readFileSync(filePath)).digest('hex')
66
const walkSync = dir => readdirSync(dir, { withFileTypes: true }).flatMap(file =>
@@ -20,10 +20,41 @@ function formatBytes (bytes, decimals = 2) {
2020
return `${formattedSize} ${sizes[i]}`
2121
}
2222

23-
function generatePrecacheManifest () {
23+
function generateDummyPrecacheManifest () {
24+
// A dummy manifest,to be easily referenced in the public/sw.js when patched
25+
// This will be pathced with custom assets urls after Next builds
26+
const manifest = [{
27+
url: '/dummy/path/test1.js',
28+
revision: 'rev-123'
29+
}]
30+
31+
const output = 'sw/precache-manifest.json'
32+
writeFileSync(output, JSON.stringify(manifest, null, 2))
33+
34+
console.log(`Created precache manifest at ${output}.`)
35+
}
36+
37+
function patchSwAssetsURL (assetUrlsArray) {
38+
const fullPath = resolve('public/sw.js')
39+
let content = readFileSync(fullPath, 'utf-8')
40+
const patchedArray = JSON.stringify(assetUrlsArray)
41+
const escapedPatchedArrayJson = patchedArray.replace(/"/g, '\\"')
42+
const regex = /JSON\.parse\(\s*'\[\{"url":"\/dummy\/path\/test1\.js","revision":"rev-123"\}\]'\s*\)/
43+
if (!regex.test(content)) {
44+
console.warn('⚠️ No match found for precache manifest in sw.js')
45+
return
46+
}
47+
48+
content = content.replace( regex, () => {
49+
return `JSON.parse('${escapedPatchedArrayJson}')`
50+
})
51+
writeFileSync(fullPath, content, 'utf-8')
52+
console.log('✅ Patched service worker cached assets')
53+
}
54+
55+
async function addStaticAssetsInServiceWorker () {
2456
const manifest = []
2557
let size = 0
26-
2758
const addToManifest = (filePath, url, s) => {
2859
const revision = getRevision(filePath)
2960
manifest.push({ url, revision })
@@ -35,7 +66,9 @@ function generatePrecacheManifest () {
3566
const staticMatch = f => [/\.(gif|jpe?g|ico|png|ttf|woff|woff2)$/].some(m => m.test(f))
3667
staticFiles.filter(staticMatch).forEach(file => {
3768
const stats = statSync(file)
38-
addToManifest(file, file.slice(staticDir.length), stats.size)
69+
// Normalize path separators for URLs
70+
const url = file.slice(staticDir.length).replace(/\\/g, '/')
71+
addToManifest(file, url, stats.size)
3972
})
4073

4174
const pagesDir = join(__dirname, '../pages')
@@ -45,17 +78,60 @@ function generatePrecacheManifest () {
4578
const pageMatch = f => precacheURLs.some(url => fileToUrl(f) === url)
4679
pagesFiles.filter(pageMatch).forEach(file => {
4780
const stats = statSync(file)
48-
// This is not ideal since dependencies of the pages may have changed
49-
// but we would still generate the same revision ...
50-
// The ideal solution would be to create a revision from the file generated by webpack
51-
// in .next/server/pages but the file may not exist yet when we run this script
5281
addToManifest(file, fileToUrl(file), stats.size)
5382
})
5483

84+
const nextStaticDir = join(__dirname, '../.next/static')
85+
// Wait until folder is emitted
86+
console.log('⏳ Waiting for .next/static to be emitted...')
87+
let folderRetries = 0
88+
while (!existsSync(nextStaticDir) && folderRetries < 10) {
89+
// eslint-disable-next-line no-await-in-loop
90+
await new Promise(resolve => setTimeout(resolve, 500))
91+
folderRetries++
92+
}
93+
94+
if (!existsSync(nextStaticDir)) {
95+
// Still write the manifest with whatever was collected from public/ and pages/
96+
const output = 'sw/precache-manifest.json'
97+
writeFileSync(output, JSON.stringify(manifest, null, 2))
98+
console.warn(
99+
`⚠️ .next/static not found. Created precache manifest at ${output} with only public/ and pages/ assets.`
100+
)
101+
return
102+
}
103+
// Now watch for stabilization (files are emitted asynchronously)
104+
let lastFileCount = 0
105+
let stableCount = 0
106+
const maxWaitMs = 60000
107+
const startTime = Date.now()
108+
while (stableCount < 3 && (Date.now() - startTime) < maxWaitMs) {
109+
const files = walkSync(nextStaticDir)
110+
if (files.length === lastFileCount) {
111+
stableCount++
112+
} else {
113+
stableCount = 0
114+
lastFileCount = files.length
115+
}
116+
await new Promise(resolve => setTimeout(resolve, 500))
117+
}
118+
// finally generate manifest
119+
const nextStaticFiles = walkSync(nextStaticDir)
120+
nextStaticFiles.forEach(file => {
121+
const stats = statSync(file)
122+
// Normalize path separators for URLs
123+
const url = `/_next/static${file.slice(nextStaticDir.length).replace(/\\/g, '/')}`
124+
addToManifest(file, url, stats.size)
125+
})
126+
// write manifest
55127
const output = 'sw/precache-manifest.json'
56128
writeFileSync(output, JSON.stringify(manifest, null, 2))
57-
58-
console.log(`Created precache manifest at ${output}. Cache will include ${manifest.length} URLs with a size of ${formatBytes(size)}.`)
129+
console.log(
130+
`✅ Created precache manifest at ${output}. Cache will include ${manifest.length} URLs with a size of ${formatBytes(size)}.`
131+
)
132+
const data = readFileSync('sw/precache-manifest.json', 'utf-8')
133+
const manifestArray = JSON.parse(data)
134+
patchSwAssetsURL(manifestArray)
59135
}
60136

61-
module.exports = { generatePrecacheManifest }
137+
module.exports = { generateDummyPrecacheManifest, addStaticAssetsInServiceWorker }

0 commit comments

Comments
 (0)