Skip to content

Commit 68b9158

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

File tree

2 files changed

+120
-4
lines changed

2 files changed

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

0 commit comments

Comments
 (0)