Skip to content

Commit 2f9ccd5

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

File tree

2 files changed

+143
-14
lines changed

2 files changed

+143
-14
lines changed

next.config.js

Lines changed: 21 additions & 2 deletions
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 { generateDummyPrecacheManifest, addStaticAssetsInServiceWorker } = require('./sw/build.js')
44
const webpack = require('webpack')
55

66
let isProd = process.env.NODE_ENV === 'production'
@@ -214,7 +214,7 @@ module.exports = withPlausibleProxy()({
214214
},
215215
webpack: (config, { isServer, dev, defaultLoaders }) => {
216216
if (isServer) {
217-
generatePrecacheManifest()
217+
generateDummyPrecacheManifest()
218218
const workboxPlugin = new InjectManifest({
219219
// ignore the precached manifest which includes the webpack assets
220220
// since they are not useful to us
@@ -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: 122 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const { createHash } = require('crypto')
2-
const { readdirSync, readFileSync, statSync, writeFileSync } = require('fs')
2+
const { readdirSync, readFileSync, statSync, writeFileSync, existsSync, renameSync } = require('fs')
33
const { join } = require('path')
44

55
const getRevision = filePath => createHash('md5').update(readFileSync(filePath)).digest('hex')
@@ -20,10 +20,68 @@ function formatBytes (bytes, decimals = 2) {
2020
return `${formattedSize} ${sizes[i]}`
2121
}
2222

23-
function generatePrecacheManifest () {
23+
function escapeForSingleQuotedJsString (str) {
24+
return str
25+
.replace(/\\/g, '\\\\')
26+
.replace(/'/g, "\\'")
27+
.replace(/\r/g, '\\r')
28+
.replace(/\n/g, '\\n')
29+
.replace(/\$/g, '\\$')
30+
}
31+
32+
function generateDummyPrecacheManifest () {
33+
// A dummy manifest,to be easily referenced in the public/sw.js when patched
34+
// This will be pathced with custom assets urls after Next builds
35+
const manifest = [{
36+
url: '/dummy/path/test1.js',
37+
revision: 'rev-123'
38+
}]
39+
40+
const output = join(__dirname, 'precache-manifest.json')
41+
writeFileSync(output, JSON.stringify(manifest, null, 2))
42+
43+
console.log(`Created precache manifest at ${output}.`)
44+
}
45+
46+
function patchSwAssetsURL (assetUrlsArray) {
47+
const fullPath = join(__dirname, '../public/sw.js')
48+
let content = readFileSync(fullPath, 'utf-8')
49+
const patchedArray = JSON.stringify(assetUrlsArray)
50+
const escapedPatchedArrayJson = escapeForSingleQuotedJsString(patchedArray)
51+
52+
// Robust regex: matches JSON.parse('...') or JSON.parse("...") containing the dummy manifest
53+
// Looks for the dummy manifest's url and revision keys as a marker
54+
// This version does not use backreferences inside character classes
55+
const regex = /JSON\.parse\((['"])\s*\[\s*\{\s*(['"])url\2\s*:\s*(['"])[^'"]+\3\s*,\s*(['"])revision\4\s*:\s*(['"])[^'"]+\5\s*\}\s*\]\s*\1\)/
56+
57+
if (!regex.test(content)) {
58+
console.warn('⚠️ No match found for precache manifest in sw.js. Service worker will NOT be patched.')
59+
return
60+
}
61+
62+
content = content.replace(regex, () => {
63+
return `JSON.parse('${escapedPatchedArrayJson}')`
64+
})
65+
66+
// Atomic write: write to temp file, then rename
67+
const tempPath = fullPath + '.tmp'
68+
try {
69+
writeFileSync(tempPath, content, 'utf-8')
70+
renameSync(tempPath, fullPath)
71+
console.log('✅ Patched service worker cached assets')
72+
} catch (err) {
73+
console.error('❌ Failed to patch service worker:', err)
74+
// Clean up temp file if exists
75+
if (existsSync(tempPath)) {
76+
try { require('fs').unlinkSync(tempPath) } catch (_) {}
77+
}
78+
throw err
79+
}
80+
}
81+
82+
async function addStaticAssetsInServiceWorker () {
2483
const manifest = []
2584
let size = 0
26-
2785
const addToManifest = (filePath, url, s) => {
2886
const revision = getRevision(filePath)
2987
manifest.push({ url, revision })
@@ -35,7 +93,9 @@ function generatePrecacheManifest () {
3593
const staticMatch = f => [/\.(gif|jpe?g|ico|png|ttf|woff|woff2)$/].some(m => m.test(f))
3694
staticFiles.filter(staticMatch).forEach(file => {
3795
const stats = statSync(file)
38-
addToManifest(file, file.slice(staticDir.length), stats.size)
96+
// Normalize path separators for URLs
97+
const url = file.slice(staticDir.length).replace(/\\/g, '/')
98+
addToManifest(file, url, stats.size)
3999
})
40100

41101
const pagesDir = join(__dirname, '../pages')
@@ -45,17 +105,67 @@ function generatePrecacheManifest () {
45105
const pageMatch = f => precacheURLs.some(url => fileToUrl(f) === url)
46106
pagesFiles.filter(pageMatch).forEach(file => {
47107
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
52108
addToManifest(file, fileToUrl(file), stats.size)
53109
})
54110

55-
const output = 'sw/precache-manifest.json'
56-
writeFileSync(output, JSON.stringify(manifest, null, 2))
111+
const nextStaticDir = join(__dirname, '../.next/static')
112+
// Wait until folder is emitted
113+
console.log('⏳ Waiting for .next/static to be emitted...')
114+
let folderRetries = 0
115+
while (!existsSync(nextStaticDir) && folderRetries < 10) {
116+
// eslint-disable-next-line no-await-in-loop
117+
await new Promise(resolve => setTimeout(resolve, 500))
118+
folderRetries++
119+
}
57120

58-
console.log(`Created precache manifest at ${output}. Cache will include ${manifest.length} URLs with a size of ${formatBytes(size)}.`)
121+
if (!existsSync(nextStaticDir)) {
122+
// Still write the manifest with whatever was collected from public/ and pages/
123+
const output = join(__dirname, 'precache-manifest.json')
124+
writeFileSync(output, JSON.stringify(manifest, null, 2))
125+
console.warn(
126+
`⚠️ .next/static not found. Created precache manifest at ${output} with only public/ and pages/ assets.`
127+
)
128+
// Patch the service worker with the available manifest
129+
patchSwAssetsURL(manifest)
130+
return manifest
131+
}
132+
133+
function snapshot (files) {
134+
return files.map(f => `${f}:${statSync(f).size}`).join(',')
135+
}
136+
// Now watch for stabilization (files are emitted asynchronously)
137+
let lastSnapshot = ''
138+
let stableCount = 0
139+
const maxWaitMs = 60000
140+
const startTime = Date.now()
141+
while (stableCount < 3 && (Date.now() - startTime) < maxWaitMs) {
142+
const files = walkSync(nextStaticDir)
143+
const currentSnapshot = snapshot(files)
144+
if (currentSnapshot === lastSnapshot) {
145+
stableCount++
146+
} else {
147+
lastSnapshot = currentSnapshot
148+
stableCount = 0
149+
}
150+
await new Promise(resolve => setTimeout(resolve, 500))
151+
}
152+
// finally generate manifest
153+
const nextStaticFiles = walkSync(nextStaticDir)
154+
nextStaticFiles.forEach(file => {
155+
const stats = statSync(file)
156+
// Normalize path separators for URLs
157+
const url = `/_next/static${file.slice(nextStaticDir.length).replace(/\\/g, '/')}`
158+
addToManifest(file, url, stats.size)
159+
})
160+
// write manifest
161+
const output = join(__dirname, 'precache-manifest.json')
162+
writeFileSync(output, JSON.stringify(manifest, null, 2))
163+
console.log(
164+
`✅ Created precache manifest at ${output}. Cache will include ${manifest.length} URLs with a size of ${formatBytes(size)}.`
165+
)
166+
const data = readFileSync(output, 'utf-8')
167+
const manifestArray = JSON.parse(data)
168+
patchSwAssetsURL(manifestArray)
59169
}
60170

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

0 commit comments

Comments
 (0)