Skip to content

Commit c47243a

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

File tree

2 files changed

+124
-15
lines changed

2 files changed

+124
-15
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 & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
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 =>
77
file.isDirectory() ? walkSync(join(dir, file.name)) : join(dir, file.name))
88

9-
function formatBytes (bytes, decimals = 2) {
9+
function formatBytes(bytes, decimals = 2) {
1010
if (bytes === 0) {
1111
return '0 B'
1212
}
@@ -20,10 +20,51 @@ 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+
33+
function generateDummyPrecacheManifest() {
34+
// A dummy manifest,to be easily referenced in the public/sw.js when patched
35+
// This will be pathced with custom assets urls after Next builds
36+
const manifest = [{
37+
url: '/dummy/path/test1.js',
38+
revision: 'rev-123'
39+
}]
40+
41+
const output = 'sw/precache-manifest.json'
42+
writeFileSync(output, JSON.stringify(manifest, null, 2))
43+
44+
console.log(`Created precache manifest at ${output}.`)
45+
}
46+
47+
function patchSwAssetsURL(assetUrlsArray) {
48+
const fullPath = join(__dirname, '../public/sw.js')
49+
let content = readFileSync(fullPath, 'utf-8')
50+
const patchedArray = JSON.stringify(assetUrlsArray)
51+
const escapedPatchedArrayJson = escapeForSingleQuotedJsString(patchedArray)
52+
const regex = /JSON\.parse\(\s*'\[\{"url":"\/dummy\/path\/test1\.js","revision":"rev-123"\}\]'\s*\)/
53+
if (!regex.test(content)) {
54+
console.warn('⚠️ No match found for precache manifest in sw.js')
55+
return
56+
}
57+
58+
content = content.replace(regex, () => {
59+
return `JSON.parse('${escapedPatchedArrayJson}')`
60+
})
61+
writeFileSync(fullPath, content, 'utf-8')
62+
console.log('✅ Patched service worker cached assets')
63+
}
64+
65+
async function addStaticAssetsInServiceWorker() {
2466
const manifest = []
2567
let size = 0
26-
2768
const addToManifest = (filePath, url, s) => {
2869
const revision = getRevision(filePath)
2970
manifest.push({ url, revision })
@@ -35,7 +76,9 @@ function generatePrecacheManifest () {
3576
const staticMatch = f => [/\.(gif|jpe?g|ico|png|ttf|woff|woff2)$/].some(m => m.test(f))
3677
staticFiles.filter(staticMatch).forEach(file => {
3778
const stats = statSync(file)
38-
addToManifest(file, file.slice(staticDir.length), stats.size)
79+
// Normalize path separators for URLs
80+
const url = file.slice(staticDir.length).replace(/\\/g, '/')
81+
addToManifest(file, url, stats.size)
3982
})
4083

4184
const pagesDir = join(__dirname, '../pages')
@@ -45,17 +88,64 @@ function generatePrecacheManifest () {
4588
const pageMatch = f => precacheURLs.some(url => fileToUrl(f) === url)
4689
pagesFiles.filter(pageMatch).forEach(file => {
4790
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
5291
addToManifest(file, fileToUrl(file), stats.size)
5392
})
5493

94+
const nextStaticDir = join(__dirname, '../.next/static')
95+
// Wait until folder is emitted
96+
console.log('⏳ Waiting for .next/static to be emitted...')
97+
let folderRetries = 0
98+
while (!existsSync(nextStaticDir) && folderRetries < 10) {
99+
// eslint-disable-next-line no-await-in-loop
100+
await new Promise(resolve => setTimeout(resolve, 500))
101+
folderRetries++
102+
}
103+
104+
if (!existsSync(nextStaticDir)) {
105+
// Still write the manifest with whatever was collected from public/ and pages/
106+
const output = 'sw/precache-manifest.json'
107+
writeFileSync(output, JSON.stringify(manifest, null, 2))
108+
console.warn(
109+
`⚠️ .next/static not found. Created precache manifest at ${output} with only public/ and pages/ assets.`
110+
111+
)
112+
const data = readFileSync('sw/precache-manifest.json', 'utf-8')
113+
const manifestArray = JSON.parse(data)
114+
patchSwAssetsURL(manifestArray)
115+
return
116+
}
117+
// Now watch for stabilization (files are emitted asynchronously)
118+
let lastFileCount = 0
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+
if (files.length === lastFileCount) {
125+
stableCount++
126+
} else {
127+
stableCount = 0
128+
lastFileCount = files.length
129+
}
130+
await new Promise(resolve => setTimeout(resolve, 500))
131+
}
132+
// finally generate manifest
133+
const nextStaticFiles = walkSync(nextStaticDir)
134+
nextStaticFiles.forEach(file => {
135+
const stats = statSync(file)
136+
// Normalize path separators for URLs
137+
const url = `/_next/static${file.slice(nextStaticDir.length).replace(/\\/g, '/')}`
138+
addToManifest(file, url, stats.size)
139+
})
140+
// write manifest
55141
const output = 'sw/precache-manifest.json'
56142
writeFileSync(output, JSON.stringify(manifest, null, 2))
57-
58-
console.log(`Created precache manifest at ${output}. Cache will include ${manifest.length} URLs with a size of ${formatBytes(size)}.`)
143+
console.log(
144+
`✅ Created precache manifest at ${output}. Cache will include ${manifest.length} URLs with a size of ${formatBytes(size)}.`
145+
)
146+
const data = readFileSync('sw/precache-manifest.json', 'utf-8')
147+
const manifestArray = JSON.parse(data)
148+
patchSwAssetsURL(manifestArray)
59149
}
60150

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

0 commit comments

Comments
 (0)