Skip to content

Commit 886176f

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

File tree

2 files changed

+124
-4
lines changed

2 files changed

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

0 commit comments

Comments
 (0)