Skip to content

Commit a63f48f

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

File tree

2 files changed

+124
-14
lines changed

2 files changed

+124
-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: 103 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 } = require('fs')
33
const { join } = require('path')
44

55
const getRevision = filePath => createHash('md5').update(readFileSync(filePath)).digest('hex')
@@ -20,10 +20,50 @@ 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+
const regex = /JSON\.parse\(\s*'\[\{"url":"\/dummy\/path\/test1\.js","revision":"rev-123"\}\]'\s*\)/
52+
if (!regex.test(content)) {
53+
console.warn('⚠️ No match found for precache manifest in sw.js')
54+
return
55+
}
56+
57+
content = content.replace(regex, () => {
58+
return `JSON.parse('${escapedPatchedArrayJson}')`
59+
})
60+
writeFileSync(fullPath, content, 'utf-8')
61+
console.log('✅ Patched service worker cached assets')
62+
}
63+
64+
async function addStaticAssetsInServiceWorker () {
2465
const manifest = []
2566
let size = 0
26-
2767
const addToManifest = (filePath, url, s) => {
2868
const revision = getRevision(filePath)
2969
manifest.push({ url, revision })
@@ -35,7 +75,9 @@ function generatePrecacheManifest () {
3575
const staticMatch = f => [/\.(gif|jpe?g|ico|png|ttf|woff|woff2)$/].some(m => m.test(f))
3676
staticFiles.filter(staticMatch).forEach(file => {
3777
const stats = statSync(file)
38-
addToManifest(file, file.slice(staticDir.length), stats.size)
78+
// Normalize path separators for URLs
79+
const url = file.slice(staticDir.length).replace(/\\/g, '/')
80+
addToManifest(file, url, stats.size)
3981
})
4082

4183
const pagesDir = join(__dirname, '../pages')
@@ -45,17 +87,66 @@ function generatePrecacheManifest () {
4587
const pageMatch = f => precacheURLs.some(url => fileToUrl(f) === url)
4688
pagesFiles.filter(pageMatch).forEach(file => {
4789
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
5290
addToManifest(file, fileToUrl(file), stats.size)
5391
})
5492

55-
const output = 'sw/precache-manifest.json'
56-
writeFileSync(output, JSON.stringify(manifest, null, 2))
93+
const nextStaticDir = join(__dirname, '../.next/static')
94+
// Wait until folder is emitted
95+
console.log('⏳ Waiting for .next/static to be emitted...')
96+
let folderRetries = 0
97+
while (!existsSync(nextStaticDir) && folderRetries < 10) {
98+
// eslint-disable-next-line no-await-in-loop
99+
await new Promise(resolve => setTimeout(resolve, 500))
100+
folderRetries++
101+
}
102+
103+
if (!existsSync(nextStaticDir)) {
104+
// Still write the manifest with whatever was collected from public/ and pages/
105+
const output = join(__dirname, 'precache-manifest.json')
106+
writeFileSync(output, JSON.stringify(manifest, null, 2))
107+
console.warn(
108+
`⚠️ .next/static not found. Created precache manifest at ${output} with only public/ and pages/ assets.`
109+
110+
)
111+
return manifest
112+
}
57113

58-
console.log(`Created precache manifest at ${output}. Cache will include ${manifest.length} URLs with a size of ${formatBytes(size)}.`)
114+
function snapshot (files) {
115+
return files.map(f => `${f}:${statSync(f).size}`).join(',')
116+
}
117+
// Now watch for stabilization (files are emitted asynchronously)
118+
let lastSnapshot = ''
119+
let stableCount = 0
120+
const maxWaitMs = 60000
121+
const startTime = Date.now()
122+
while (stableCount < 3 && (Date.now() - startTime) < maxWaitMs) {
123+
const files = walkSync(nextStaticDir)
124+
const currentSnapshot = snapshot(files)
125+
if (currentSnapshot === lastSnapshot) {
126+
stableCount++
127+
} else {
128+
lastSnapshot = currentSnapshot
129+
stableCount = 0
130+
}
131+
await new Promise(resolve => setTimeout(resolve, 500))
132+
}
133+
// finally generate manifest
134+
const nextStaticFiles = walkSync(nextStaticDir)
135+
nextStaticFiles.forEach(file => {
136+
const stats = statSync(file)
137+
// Normalize path separators for URLs
138+
const url = `/_next/static${file.slice(nextStaticDir.length).replace(/\\/g, '/')}`
139+
addToManifest(file, url, stats.size)
140+
})
141+
// write manifest
142+
const output = join(__dirname, 'precache-manifest.json')
143+
writeFileSync(output, JSON.stringify(manifest, null, 2))
144+
console.log(
145+
`✅ Created precache manifest at ${output}. Cache will include ${manifest.length} URLs with a size of ${formatBytes(size)}.`
146+
)
147+
const data = readFileSync(output, 'utf-8')
148+
const manifestArray = JSON.parse(data)
149+
patchSwAssetsURL(manifestArray)
59150
}
60151

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

0 commit comments

Comments
 (0)