Skip to content

Commit 1d081f5

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

File tree

2 files changed

+127
-4
lines changed

2 files changed

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

0 commit comments

Comments
 (0)