Skip to content

Commit fc85fa9

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

File tree

2 files changed

+108
-12
lines changed

2 files changed

+108
-12
lines changed

next.config.js

Lines changed: 20 additions & 1 deletion
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 { generatePrecacheManifest, addStaticAssetsInServiceWorker } = require('./sw/build.js')
44
const webpack = require('webpack')
55

66
let isProd = process.env.NODE_ENV === 'production'
@@ -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 & 11 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 =>
@@ -21,9 +21,41 @@ function formatBytes (bytes, decimals = 2) {
2121
}
2222

2323
function generatePrecacheManifest () {
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*'(\[.*?\])'\s*\)/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(
49+
regex,
50+
`Q=JSON.parse('${escapedPatchedArrayJson}');`
51+
)
52+
writeFileSync(fullPath, content, 'utf-8')
53+
console.log('✅ Patched service worker cached assets')
54+
}
55+
56+
async function addStaticAssetsInServiceWorker () {
2457
const manifest = []
2558
let size = 0
26-
2759
const addToManifest = (filePath, url, s) => {
2860
const revision = getRevision(filePath)
2961
manifest.push({ url, revision })
@@ -35,7 +67,9 @@ function generatePrecacheManifest () {
3567
const staticMatch = f => [/\.(gif|jpe?g|ico|png|ttf|woff|woff2)$/].some(m => m.test(f))
3668
staticFiles.filter(staticMatch).forEach(file => {
3769
const stats = statSync(file)
38-
addToManifest(file, file.slice(staticDir.length), stats.size)
70+
// Normalize path separators for URLs
71+
const url = file.slice(staticDir.length).replace(/\\/g, '/')
72+
addToManifest(file, url, stats.size)
3973
})
4074

4175
const pagesDir = join(__dirname, '../pages')
@@ -45,17 +79,60 @@ function generatePrecacheManifest () {
4579
const pageMatch = f => precacheURLs.some(url => fileToUrl(f) === url)
4680
pagesFiles.filter(pageMatch).forEach(file => {
4781
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
5282
addToManifest(file, fileToUrl(file), stats.size)
5383
})
5484

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

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

0 commit comments

Comments
 (0)