Skip to content

Commit 0bcb28e

Browse files
committed
perf(dev): add V8 bytecode caching for bundled dev server
1 parent 8306ad4 commit 0bcb28e

File tree

2 files changed

+305
-5
lines changed

2 files changed

+305
-5
lines changed
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/**
2+
* V8 Bytecode Cache for Dev Server Bundle
3+
*
4+
* This module provides bytecode caching specifically for the bundled dev server,
5+
* avoiding the overhead of parsing large JavaScript files on every startup.
6+
*
7+
* Unlike Node.js's NODE_COMPILE_CACHE which caches everything (including user code),
8+
* this only caches the dev server bundle which doesn't change between restarts.
9+
*
10+
* Important: Bytecode is saved AFTER a warmup period to capture JIT-optimized code.
11+
* V8's TurboFan compiler optimizes hot code paths, and we want to cache that.
12+
*
13+
* Debug logging: Set DEBUG=next:bytecode-cache to see cache hit/miss info
14+
*/
15+
16+
import { Script } from 'vm'
17+
import { createRequire } from 'module'
18+
import {
19+
readFileSync,
20+
writeFileSync,
21+
existsSync,
22+
mkdirSync,
23+
statSync,
24+
} from 'fs'
25+
import { createHash } from 'crypto'
26+
import { join, dirname, basename } from 'path'
27+
import { tmpdir } from 'os'
28+
import setupDebug from 'next/dist/compiled/debug'
29+
30+
const debug = setupDebug('next:bytecode-cache')
31+
32+
const CACHE_VERSION = 2
33+
// How long to wait before saving bytecode (allows JIT warmup)
34+
const WARMUP_DELAY_MS = 10_000
35+
36+
interface CacheMetadata {
37+
version: number
38+
sourceHash: string
39+
nodeVersion: string
40+
v8Version: string
41+
mtime: number
42+
}
43+
44+
/**
45+
* Get the cache directory for bytecode files
46+
*/
47+
function getCacheDir(): string {
48+
// Use .next/cache/bytecode if in a project, otherwise use temp
49+
const projectCacheDir = join(process.cwd(), '.next', 'cache', 'bytecode')
50+
if (existsSync(join(process.cwd(), '.next'))) {
51+
return projectCacheDir
52+
}
53+
// Fallback to a temp location (use os.tmpdir() for cross-platform support)
54+
return join(tmpdir(), 'next-bytecode-cache', process.version)
55+
}
56+
57+
/**
58+
* Generate a hash of the source file for cache validation
59+
*/
60+
function hashSource(source: string): string {
61+
return createHash('sha256').update(source).digest('hex').slice(0, 16)
62+
}
63+
64+
/**
65+
* Get the cache file paths for a given module
66+
*/
67+
function getCachePaths(
68+
modulePath: string,
69+
cacheDir: string
70+
): { bytecode: string; metadata: string } {
71+
const moduleHash = createHash('sha256')
72+
.update(modulePath)
73+
.digest('hex')
74+
.slice(0, 16)
75+
return {
76+
bytecode: join(cacheDir, `${moduleHash}.bytecode`),
77+
metadata: join(cacheDir, `${moduleHash}.json`),
78+
}
79+
}
80+
81+
/**
82+
* Check if cached bytecode is valid
83+
*/
84+
function isCacheValid(
85+
metadata: CacheMetadata,
86+
sourceHash: string,
87+
sourceMtime: number
88+
): boolean {
89+
return (
90+
metadata.version === CACHE_VERSION &&
91+
metadata.sourceHash === sourceHash &&
92+
metadata.nodeVersion === process.version &&
93+
metadata.v8Version === (process.versions.v8 || '') &&
94+
metadata.mtime === sourceMtime
95+
)
96+
}
97+
98+
// Track scripts that need deferred cache saving after warmup
99+
const pendingCacheSaves: Array<{
100+
script: Script
101+
bytecodePath: string
102+
metadataPath: string
103+
sourceHash: string
104+
sourceMtime: number
105+
cacheDir: string
106+
}> = []
107+
108+
// Track which bytecode paths are already pending to avoid duplicates
109+
const pendingBytecodePaths = new Set<string>()
110+
111+
let warmupScheduled = false
112+
113+
/**
114+
* Schedule deferred bytecode cache saves after JIT warmup
115+
*/
116+
function scheduleDeferredCacheSave(): void {
117+
if (warmupScheduled || pendingCacheSaves.length === 0) return
118+
warmupScheduled = true
119+
120+
debug('scheduling bytecode cache save after %dms warmup', WARMUP_DELAY_MS)
121+
122+
setTimeout(() => {
123+
for (const pending of pendingCacheSaves) {
124+
try {
125+
// Use createCachedData() to get JIT-optimized bytecode
126+
const cachedData = pending.script.createCachedData()
127+
if (cachedData && cachedData.length > 0) {
128+
mkdirSync(pending.cacheDir, { recursive: true })
129+
writeFileSync(pending.bytecodePath, cachedData)
130+
writeFileSync(
131+
pending.metadataPath,
132+
JSON.stringify({
133+
version: CACHE_VERSION,
134+
sourceHash: pending.sourceHash,
135+
nodeVersion: process.version,
136+
v8Version: process.versions.v8 || '',
137+
mtime: pending.sourceMtime,
138+
} satisfies CacheMetadata)
139+
)
140+
debug(
141+
'wrote bytecode cache (%d KB) to %s',
142+
Math.round(cachedData.length / 1024),
143+
pending.bytecodePath
144+
)
145+
}
146+
} catch (err) {
147+
debug('failed to write bytecode cache: %s', err)
148+
}
149+
}
150+
pendingCacheSaves.length = 0
151+
pendingBytecodePaths.clear()
152+
warmupScheduled = false // Reset flag to allow future cache saves
153+
}, WARMUP_DELAY_MS).unref() // unref so it doesn't keep the process alive
154+
}
155+
156+
/**
157+
* Load a module with bytecode caching
158+
*
159+
* This function:
160+
* 1. Checks if valid cached bytecode exists
161+
* 2. If yes, loads using cached bytecode (faster)
162+
* 3. If no, compiles the script and schedules bytecode save after JIT warmup
163+
*
164+
* The deferred save captures JIT-optimized bytecode from V8's TurboFan compiler,
165+
* resulting in faster execution on subsequent startups.
166+
*
167+
* @param modulePath - Absolute path to the JavaScript file
168+
* @returns The module exports
169+
*/
170+
export function loadWithBytecodeCache(modulePath: string): any {
171+
const cacheDir = getCacheDir()
172+
const { bytecode: bytecodePath, metadata: metadataPath } = getCachePaths(
173+
modulePath,
174+
cacheDir
175+
)
176+
177+
// Read source file
178+
const source = readFileSync(modulePath, 'utf-8')
179+
const sourceHash = hashSource(source)
180+
const sourceMtime = statSync(modulePath).mtimeMs
181+
182+
let cachedData: Buffer | undefined
183+
let hadValidCache = false
184+
185+
const moduleBasename = basename(modulePath)
186+
187+
// Try to load cached bytecode
188+
if (existsSync(metadataPath) && existsSync(bytecodePath)) {
189+
try {
190+
const metadata: CacheMetadata = JSON.parse(
191+
readFileSync(metadataPath, 'utf-8')
192+
)
193+
if (isCacheValid(metadata, sourceHash, sourceMtime)) {
194+
cachedData = readFileSync(bytecodePath)
195+
hadValidCache = true
196+
debug(
197+
'bytecode cache HIT for %s (%d KB)',
198+
moduleBasename,
199+
Math.round(cachedData.length / 1024)
200+
)
201+
} else {
202+
debug(
203+
'bytecode cache STALE for %s (version/hash mismatch)',
204+
moduleBasename
205+
)
206+
}
207+
} catch {
208+
debug('bytecode cache INVALID for %s (corrupted)', moduleBasename)
209+
}
210+
} else {
211+
debug('bytecode cache MISS for %s (no cache file)', moduleBasename)
212+
}
213+
214+
// Wrap source in a function to capture exports
215+
const wrappedSource = `(function(exports, require, module, __filename, __dirname) {
216+
${source}
217+
});`
218+
219+
// Create script with cached bytecode if available
220+
const script = new Script(wrappedSource, {
221+
filename: modulePath,
222+
cachedData,
223+
})
224+
225+
// Check if V8 rejected the cached data despite metadata validation passing
226+
// This can happen due to corruption, platform incompatibility, or other
227+
// V8-internal reasons beyond version mismatches. If rejected, we need to
228+
// regenerate the cache to avoid infinite recompilation on every startup.
229+
if (script.cachedDataRejected) {
230+
hadValidCache = false
231+
debug(
232+
'bytecode cache REJECTED for %s (V8 validation failed)',
233+
moduleBasename
234+
)
235+
}
236+
237+
// If no valid cache, schedule deferred save after JIT warmup
238+
// Only add to pending saves if this bytecode path is not already pending
239+
// This prevents duplicate saves for the same module within a process
240+
if (!hadValidCache && !pendingBytecodePaths.has(bytecodePath)) {
241+
pendingCacheSaves.push({
242+
script,
243+
bytecodePath,
244+
metadataPath,
245+
sourceHash,
246+
sourceMtime,
247+
cacheDir,
248+
})
249+
pendingBytecodePaths.add(bytecodePath)
250+
scheduleDeferredCacheSave()
251+
}
252+
253+
// Execute the script
254+
const compiledWrapper = script.runInThisContext()
255+
256+
// Create module-like object
257+
const moduleObj = { exports: {} }
258+
// Create a require function scoped to the module being loaded
259+
// This ensures relative requires resolve correctly relative to the module's location
260+
const moduleRequire = createRequire(modulePath)
261+
262+
compiledWrapper(
263+
moduleObj.exports,
264+
moduleRequire,
265+
moduleObj,
266+
modulePath,
267+
dirname(modulePath)
268+
)
269+
270+
return moduleObj.exports
271+
}
272+
273+
/**
274+
* Check if bytecode caching is enabled
275+
*/
276+
export function isBytecodeCacheEnabled(): boolean {
277+
return process.env.NEXT_DISABLE_BYTECODE_CACHE !== '1'
278+
}
279+
280+
/**
281+
* Clear the bytecode cache
282+
*/
283+
export function clearBytecodeCache(): void {
284+
const cacheDir = getCacheDir()
285+
if (existsSync(cacheDir)) {
286+
const { rmSync } = require('fs') as typeof import('fs')
287+
rmSync(cacheDir, { recursive: true, force: true })
288+
}
289+
}
Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,35 @@
11
/**
2-
* Start Server Entry Point for Bundled Dev Server
2+
* Start Server Entry Point with Bytecode Caching
33
*
4-
* This module provides a unified entry point that can load either the bundled
5-
* or unbundled dev server based on environment configuration.
4+
* This is the entry point for the dev server that uses V8 bytecode caching
5+
* to speed up loading of the bundled dev server on subsequent startups.
66
*
77
* Environment variables:
8-
* NEXT_USE_UNBUNDLED_SERVER=1 - Use unbundled server instead (for development)
8+
* NEXT_DISABLE_BYTECODE_CACHE=1 - Disable bytecode caching
9+
* NEXT_USE_UNBUNDLED_SERVER=1 - Use unbundled server instead
910
*/
1011

1112
import path from 'path'
13+
import { isBytecodeCacheEnabled, loadWithBytecodeCache } from './bytecode-cache'
1214

1315
// Determine which server to load
1416
const useBundled = process.env.NEXT_USE_UNBUNDLED_SERVER !== '1'
17+
const useBytecodeCache = isBytecodeCacheEnabled() && useBundled
1518

1619
const serverPath = useBundled
1720
? path.join(__dirname, '../../compiled/dev-server/start-server.js')
1821
: path.join(__dirname, './start-server.js')
1922

2023
// Load the server module
21-
const serverModule: typeof import('./start-server') = require(serverPath)
24+
let serverModule: typeof import('./start-server')
25+
26+
if (useBytecodeCache) {
27+
// Use bytecode caching for faster subsequent loads
28+
serverModule = loadWithBytecodeCache(serverPath)
29+
} else {
30+
// Regular require
31+
serverModule = require(serverPath)
32+
}
2233

2334
// Re-export everything from the server module
2435
export const { startServer, getRequestHandlers } = serverModule

0 commit comments

Comments
 (0)