Skip to content

Commit d7bb007

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

File tree

2 files changed

+123
-4
lines changed

2 files changed

+123
-4
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: 103 additions & 3 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 =>
@@ -58,4 +58,104 @@ function generatePrecacheManifest () {
5858
console.log(`Created precache manifest at ${output}. Cache will include ${manifest.length} URLs with a size of ${formatBytes(size)}.`)
5959
}
6060

61-
module.exports = { generatePrecacheManifest }
61+
function patchSwAssetsURL (assetUrlsArray) {
62+
const fullPath = resolve('public/sw.js')
63+
let content = readFileSync(fullPath, 'utf-8')
64+
const patchedArray = JSON.stringify(assetUrlsArray)
65+
const regex = /Q=JSON\.parse\(\s*["'](.*?)["']\s*\);/s
66+
if (!regex.test(content)) {
67+
console.warn('⚠️ No match found for precache manifest in sw.js')
68+
return
69+
}
70+
71+
content = content.replace(
72+
regex,
73+
`Q=JSON.parse("${patchedArray}");`
74+
)
75+
writeFileSync(fullPath, content, 'utf-8')
76+
console.log('✅ Patched service worker cached assets')
77+
}
78+
79+
async function addStaticAssetsInServiceWorker () {
80+
const manifest = []
81+
let size = 0
82+
const addToManifest = (filePath, url, s) => {
83+
const revision = getRevision(filePath)
84+
manifest.push({ url, revision })
85+
size += s
86+
}
87+
88+
const staticDir = join(__dirname, '../public')
89+
const staticFiles = walkSync(staticDir)
90+
const staticMatch = f => [/\.(gif|jpe?g|ico|png|ttf|woff|woff2)$/].some(m => m.test(f))
91+
staticFiles.filter(staticMatch).forEach(file => {
92+
const stats = statSync(file)
93+
// Normalize path separators for URLs
94+
const url = file.slice(staticDir.length).replace(/\\/g, '/')
95+
addToManifest(file, url, stats.size)
96+
})
97+
98+
const pagesDir = join(__dirname, '../pages')
99+
const precacheURLs = ['/offline']
100+
const pagesFiles = walkSync(pagesDir)
101+
const fileToUrl = f => f.slice(pagesDir.length).replace(/\.js$/, '')
102+
const pageMatch = f => precacheURLs.some(url => fileToUrl(f) === url)
103+
pagesFiles.filter(pageMatch).forEach(file => {
104+
const stats = statSync(file)
105+
addToManifest(file, fileToUrl(file), stats.size)
106+
})
107+
108+
const nextStaticDir = join(__dirname, '../.next/static')
109+
// Wait until folder is emitted
110+
console.log('⏳ Waiting for .next/static to be emitted...')
111+
let folderRetries = 0
112+
while (!existsSync(nextStaticDir) && folderRetries < 10) {
113+
// eslint-disable-next-line no-await-in-loop
114+
await new Promise(resolve => setTimeout(resolve, 500))
115+
folderRetries++
116+
}
117+
118+
if (!existsSync(nextStaticDir)) {
119+
// Still write the manifest with whatever was collected from public/ and pages/
120+
const output = 'sw/precache-manifest.json'
121+
writeFileSync(output, JSON.stringify(manifest, null, 2))
122+
console.warn(
123+
`⚠️ .next/static not found. Created precache manifest at ${output} with only public/ and pages/ assets.`
124+
)
125+
return
126+
}
127+
// Now watch for stabilization (files are emitted asynchronously)
128+
let lastFileCount = 0
129+
let stableCount = 0
130+
const maxWaitMs = 60000
131+
const startTime = Date.now()
132+
while (stableCount < 3 && (Date.now() - startTime) < maxWaitMs) {
133+
const files = walkSync(nextStaticDir)
134+
if (files.length === lastFileCount) {
135+
stableCount++
136+
} else {
137+
stableCount = 0
138+
lastFileCount = files.length
139+
}
140+
await new Promise(resolve => setTimeout(resolve, 500))
141+
}
142+
// finally generate manifest
143+
const nextStaticFiles = walkSync(nextStaticDir)
144+
nextStaticFiles.forEach(file => {
145+
const stats = statSync(file)
146+
// Normalize path separators for URLs
147+
const url = `/_next/static${file.slice(nextStaticDir.length).replace(/\\/g, '/')}`
148+
addToManifest(file, url, stats.size)
149+
})
150+
// write manifest
151+
const output = 'sw/precache-manifest.json'
152+
writeFileSync(output, JSON.stringify(manifest, null, 2))
153+
console.log(
154+
`✅ Created precache manifest at ${output}. Cache will include ${manifest.length} URLs with a size of ${formatBytes(size)}.`
155+
)
156+
const data = readFileSync('sw/precache-manifest.json', 'utf-8')
157+
const manifestArray = JSON.parse(data)
158+
patchSwAssetsURL(manifestArray)
159+
}
160+
161+
module.exports = { generatePrecacheManifest, addStaticAssetsInServiceWorker }

0 commit comments

Comments
 (0)