Skip to content

Commit f90b7aa

Browse files
committed
[devtools] Port cache invalidation logic from turbopack to webpack
1 parent a094280 commit f90b7aa

File tree

5 files changed

+132
-4
lines changed

5 files changed

+132
-4
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -693,5 +693,6 @@
693693
"692": "Expected clientReferenceManifest to be defined.",
694694
"693": "%s must not be used within a client component. Next.js should be preventing %s from being included in client components statically, but did not in this case.",
695695
"694": "createPrerenderPathname was called inside a client component scope.",
696-
"695": "Expected workUnitAsyncStorage to have a store."
696+
"695": "Expected workUnitAsyncStorage to have a store.",
697+
"696": "Unable to remove an invalidated webpack cache. If this issue persists you can work around it by deleting %s"
697698
}

packages/next/src/build/webpack-config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,21 @@ export function hasExternalOtelApiPackage(): boolean {
289289

290290
const UNSAFE_CACHE_REGEX = /[\\/]pages[\\/][^\\/]+(?:$|\?|#)/
291291

292+
export function getCacheDirectories(
293+
configs: webpack.Configuration[]
294+
): Set<string> {
295+
return new Set(
296+
configs
297+
.map((cfg) => {
298+
if (typeof cfg.cache === 'object' && cfg.cache.type === 'filesystem') {
299+
return cfg.cache.cacheDirectory
300+
}
301+
return null
302+
})
303+
.filter((dir) => dir != null)
304+
)
305+
}
306+
292307
export default async function getBaseWebpackConfig(
293308
dir: string,
294309
{
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
4+
const INVALIDATION_MARKER = '__nextjs_invalidated_cache'
5+
6+
/**
7+
* Atomically write an invalidation marker.
8+
*
9+
* Because attempting to delete currently open cache files could cause issues,
10+
* actual deletion of files is deferred until the next start-up (in
11+
* `checkPersistentCacheInvalidationAndCleanup`).
12+
*
13+
* In the case that no database is currently open (e.g. via a separate CLI
14+
* subcommand), you should call `cleanupPersistentCache` *after* this to eagerly
15+
* remove the cache files.
16+
*/
17+
export async function invalidatePersistentCache(cacheDirectory: string) {
18+
let file
19+
try {
20+
// We're just opening it so that `open()` creates the file.
21+
file = await fs.open(path.join(cacheDirectory, INVALIDATION_MARKER), 'w')
22+
// We don't currently write anything to the file, but we could choose to
23+
// later, e.g. a reason for the invalidation.
24+
} finally {
25+
file?.close()
26+
}
27+
}
28+
29+
/**
30+
* Called during startup. See if the cache is in a partially-completed
31+
* invalidation state. Finds and delete any invalidated cache files.
32+
*/
33+
export async function checkPersistentCacheInvalidationAndCleanup(
34+
cacheDirectory: string
35+
) {
36+
const invalidated = await fs
37+
.access(path.join(cacheDirectory, INVALIDATION_MARKER))
38+
.then(
39+
() => true,
40+
() => false
41+
)
42+
if (invalidated) {
43+
await cleanupPersistentCache(cacheDirectory)
44+
}
45+
}
46+
47+
/**
48+
* Helper for `checkPersistentCacheInvalidationAndCleanup`. You can call this to
49+
* explicitly clean up a database after running `invalidatePersistentCache` when
50+
* webpack is not running.
51+
*
52+
* You should not run this if the cache has not yet been invalidated, as this
53+
* operation is not atomic and could result in a partially-deleted and corrupted
54+
* database.
55+
*/
56+
async function cleanupPersistentCache(cacheDirectory: string) {
57+
try {
58+
await cleanupPersistentCacheInner(cacheDirectory)
59+
} catch (e) {
60+
// generate a user-friendly error message
61+
throw new Error(
62+
`Unable to remove an invalidated webpack cache. If this issue persists ` +
63+
`you can work around it by deleting ${cacheDirectory}`,
64+
{ cause: e }
65+
)
66+
}
67+
}
68+
69+
async function cleanupPersistentCacheInner(cacheDirectory: string) {
70+
const files = await fs.readdir(cacheDirectory)
71+
72+
// delete everything except the invalidation marker
73+
await Promise.all(
74+
files.map((name) =>
75+
name !== INVALIDATION_MARKER
76+
? fs.rm(path.join(cacheDirectory, name), {
77+
force: true, // ignore errors if path does not exist
78+
recursive: true,
79+
maxRetries: 2, // windows prevents deletion of open files
80+
})
81+
: null
82+
)
83+
)
84+
85+
// delete the invalidation marker last, once we're sure everything is cleaned
86+
// up
87+
await fs.rm(path.join(cacheDirectory, INVALIDATION_MARKER), {
88+
force: true,
89+
maxRetries: 2,
90+
})
91+
}

packages/next/src/client/components/react-dev-overlay/server/restart-dev-server-middleware.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@ import type { Telemetry } from '../../../../telemetry/storage'
33
import { RESTART_EXIT_CODE } from '../../../../server/lib/utils'
44
import { middlewareResponse } from './middleware-response'
55
import type { Project } from '../../../../build/swc/types'
6+
import { invalidatePersistentCache } from '../../../../build/webpack/cache-invalidation'
67

78
const EVENT_DEV_OVERLAY_RESTART_SERVER = 'DEV_OVERLAY_RESTART_SERVER'
89

910
interface RestartDevServerMiddlewareConfig {
1011
telemetry: Telemetry
1112
turbopackProject?: Project
13+
webpackCacheDirectories?: Set<string>
1214
}
1315

1416
export function getRestartDevServerMiddleware({
1517
telemetry,
1618
turbopackProject,
19+
webpackCacheDirectories,
1720
}: RestartDevServerMiddlewareConfig) {
1821
/**
1922
* Some random value between 1 and Number.MAX_SAFE_INTEGER (inclusive). The same value is returned
@@ -34,11 +37,18 @@ export function getRestartDevServerMiddleware({
3437
return middlewareResponse.methodNotAllowed(res)
3538
}
3639

37-
const invalidatePersistentCache = searchParams.has(
40+
const shouldInvalidatePersistentCache = searchParams.has(
3841
'invalidatePersistentCache'
3942
)
40-
if (invalidatePersistentCache) {
41-
await turbopackProject?.invalidatePersistentCache()
43+
if (shouldInvalidatePersistentCache) {
44+
if (webpackCacheDirectories != null) {
45+
await Promise.all(
46+
Array.from(webpackCacheDirectories).map(invalidatePersistentCache)
47+
)
48+
}
49+
if (turbopackProject != null) {
50+
await turbopackProject.invalidatePersistentCache()
51+
}
4252
}
4353

4454
telemetry.record({

packages/next/src/server/dev/hot-reloader-webpack.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { watchCompilers } from '../../build/output'
2828
import * as Log from '../../build/output/log'
2929
import getBaseWebpackConfig, {
30+
getCacheDirectories,
3031
loadProjectInfo,
3132
} from '../../build/webpack-config'
3233
import { APP_DIR_ALIAS, WEBPACK_LAYERS } from '../../lib/constants'
@@ -88,6 +89,7 @@ import { getDevOverlayFontMiddleware } from '../../client/components/react-dev-o
8889
import { getDisableDevIndicatorMiddleware } from './dev-indicator-middleware'
8990
import getWebpackBundler from '../../shared/lib/get-webpack-bundler'
9091
import { getRestartDevServerMiddleware } from '../../client/components/react-dev-overlay/server/restart-dev-server-middleware'
92+
import { checkPersistentCacheInvalidationAndCleanup } from '../../build/webpack/cache-invalidation'
9193

9294
const MILLISECONDS_IN_NANOSECOND = BigInt(1_000_000)
9395

@@ -1155,6 +1157,11 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
11551157
// @ts-ignore webpack 5
11561158
this.activeWebpackConfigs.parallelism = 1
11571159

1160+
await Promise.all(
1161+
Array.from(getCacheDirectories(this.activeWebpackConfigs)).map(
1162+
checkPersistentCacheInvalidationAndCleanup
1163+
)
1164+
)
11581165
this.multiCompiler = getWebpackBundler()(
11591166
this.activeWebpackConfigs
11601167
) as unknown as webpack.MultiCompiler
@@ -1572,6 +1579,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
15721579
getDisableDevIndicatorMiddleware(),
15731580
getRestartDevServerMiddleware({
15741581
telemetry: this.telemetry,
1582+
webpackCacheDirectories:
1583+
this.activeWebpackConfigs != null
1584+
? getCacheDirectories(this.activeWebpackConfigs)
1585+
: undefined,
15751586
}),
15761587
]
15771588
}

0 commit comments

Comments
 (0)