vercel/next.js · #100112

Write webpack dev manifests atomically

sokra · merged Oct 15, 20264 files · 260 + / 1 −
packages/next/src/lib/fs/write-atomic.ts4 + / 1 −
@@ -1,7 +1,10 @@ import { unlinkSync, writeFileSync } from 'fs' import { renameSync } from './rename' -export function writeFileAtomic(filePath: string, content: string): void {+export function writeFileAtomic(+  filePath: string,+  content: string | Buffer+): void {   const tempPath = filePath + '.tmp.' + Math.random().toString(36).slice(2)   try {     writeFileSync(tempPath, content, 'utf-8')
packages/next/src/server/dev/atomic-manifest-output-fs.test.tsadded178 + / 0 −
@@ -0,0 +1,178 @@+import fs from 'fs'+import os from 'os'+import path from 'path'+import type { webpack } from 'next/dist/compiled/webpack/webpack'+import {+  createAtomicManifestOutputFileSystem,+  isManifestFile,+} from './atomic-manifest-output-fs'++type OutputFileSystem = NonNullable<webpack.Compiler['outputFileSystem']>++function writeWith(+  outputFs: OutputFileSystem,+  filePath: string,+  content: string | Buffer+) {+  return new Promise<NodeJS.ErrnoException | null | undefined>((resolve) => {+    outputFs.writeFile(filePath, content, resolve)+  })+}++describe('isManifestFile', () => {+  it.each([+    'build-manifest.json',+    'fallback-build-manifest.json',+    'react-loadable-manifest.json',+    'server/next-font-manifest.json',+    'server/next-font-manifest.js',+    'server/app/_not-found/page_client-reference-manifest.js',+    'server/server-reference-manifest.json',+    'server/server-reference-manifest.js',+    'server/middleware-manifest.json',+    'server/middleware-build-manifest.js',+    'server/pages-manifest.json',+    'server/app-paths-manifest.json',+    'server/interception-route-rewrite-manifest.js',+    'server/subresource-integrity-manifest.json',+    'dynamic-css-manifest.json',+  ])('treats %s as a manifest', (file) => {+    expect(isManifestFile(path.join('/project/.next/dev', file))).toBe(true)+  })++  it.each([+    'server/app/page.js',+    'server/webpack-runtime.js',+    'static/chunks/main.js',+    'static/webpack/abc.webpack.hot-update.json',+    'server/manifest/page.js',+  ])('does not treat %s as a manifest', (file) => {+    expect(isManifestFile(path.join('/project/.next/dev', file))).toBe(false)+  })+})++describe('createAtomicManifestOutputFileSystem', () => {+  let dir: string+  // The real disk file system, standing in for webpack's graceful-fs.+  const baseFs = fs as unknown as OutputFileSystem++  beforeEach(() => {+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atomic-manifest-'))+  })++  afterEach(() => {+    jest.restoreAllMocks()+    fs.rmSync(dir, { recursive: true, force: true })+  })++  it('writes manifest buffers byte for byte', async () => {+    const outputFs = createAtomicManifestOutputFileSystem(baseFs)+    const target = path.join(dir, 'build-manifest.json')+    const bytes = Buffer.from([0x7b, 0xff, 0x00, 0xfe, 0x7d])++    expect(await writeWith(outputFs, target, bytes)).toBeNull()+    expect(fs.readFileSync(target).equals(bytes)).toBe(true)+    expect(fs.readdirSync(dir)).toEqual(['build-manifest.json'])+  })++  it('never exposes a partially written manifest', async () => {+    const outputFs = createAtomicManifestOutputFileSystem(baseFs)+    const target = path.join(dir, 'build-manifest.json')+    fs.writeFileSync(target, '{"old":true}')++    const realRenameSync = fs.renameSync+    let contentAtRename: string | undefined+    jest.spyOn(fs, 'renameSync').mockImplementation((from, to) => {+      // The new content is fully written to the temp file, and the target+      // still has the complete old content until the rename replaces it.+      contentAtRename = fs.readFileSync(target, 'utf8')+      expect(fs.readFileSync(from, 'utf8')).toBe('{"new":true}')+      realRenameSync(from, to)+    })++    expect(await writeWith(outputFs, target, '{"new":true}')).toBeNull()+    expect(contentAtRename).toBe('{"old":true}')+    expect(fs.readFileSync(target, 'utf8')).toBe('{"new":true}')+    expect(fs.readdirSync(dir)).toEqual(['build-manifest.json'])+  })++  it('reports a failed write once and leaves no temp file behind', async () => {+    const outputFs = createAtomicManifestOutputFileSystem(baseFs)+    const target = path.join(dir, 'build-manifest.json')+    fs.writeFileSync(target, '{"old":true}')+    jest.spyOn(fs, 'renameSync').mockImplementation(() => {+      throw Object.assign(new Error('rename failed'), { code: 'EIO' })+    })++    const callback = jest.fn()+    outputFs.writeFile(target, '{"new":true}', callback)+    await new Promise((resolve) => setImmediate(resolve))++    expect(callback).toHaveBeenCalledTimes(1)+    expect(callback.mock.calls[0][0]).toMatchObject({ code: 'EIO' })+    expect(fs.readFileSync(target, 'utf8')).toBe('{"old":true}')+    expect(fs.readdirSync(dir)).toEqual(['build-manifest.json'])+  })++  it('reports a missing directory through the callback', async () => {+    const outputFs = createAtomicManifestOutputFileSystem(baseFs)+    const err = await writeWith(+      outputFs,+      path.join(dir, 'missing', 'build-manifest.json'),+      '{}'+    )+    expect(err).toMatchObject({ code: 'ENOENT' })+    expect(fs.readdirSync(dir)).toEqual([])+  })++  it('does not call back twice when the callback throws', () => {+    const outputFs = createAtomicManifestOutputFileSystem(baseFs)+    const target = path.join(dir, 'build-manifest.json')+    const callback = jest.fn(() => {+      throw new Error('callback failed')+    })+    const ticks: Array<() => void> = []+    jest+      .spyOn(process, 'nextTick')+      .mockImplementation((fn: any, ...args: any[]) => {+        ticks.push(() => fn(...args))+      })++    outputFs.writeFile(target, '{}', callback)+    expect(callback).not.toHaveBeenCalled()+    expect(ticks).toHaveLength(1)+    expect(() => ticks[0]()).toThrow('callback failed')+    expect(callback).toHaveBeenCalledTimes(1)+  })++  it('delegates non-manifest writes and other methods', async () => {+    const writeFile = jest.fn(+      (_file: string, _content: unknown, callback: (err: null) => void) =>+        callback(null)+    )+    const mkdir = jest.fn()+    const original = { writeFile, mkdir } as unknown as OutputFileSystem+    const outputFs = createAtomicManifestOutputFileSystem(original)++    const chunk = path.join(dir, 'static', 'chunks', 'main.js')+    expect(await writeWith(outputFs, chunk, 'code')).toBeNull()+    expect(writeFile).toHaveBeenCalledWith(chunk, 'code', expect.any(Function))+    expect(outputFs.mkdir).toBe(mkdir)++    // Manifests bypass the original `writeFile`.+    const manifest = path.join(dir, 'build-manifest.json')+    expect(await writeWith(outputFs, manifest, '{}')).toBeNull()+    expect(writeFile).toHaveBeenCalledTimes(1)+    expect(fs.readFileSync(manifest, 'utf8')).toBe('{}')+  })++  it('does not modify the original file system', () => {+    const writeFile = jest.fn()+    const original = { writeFile } as unknown as OutputFileSystem+    const outputFs = createAtomicManifestOutputFileSystem(original)++    expect(outputFs).not.toBe(original)+    expect(original.writeFile).toBe(writeFile)+    expect(Object.keys(original)).toEqual(['writeFile'])+  })+})
packages/next/src/server/dev/atomic-manifest-output-fs.tsadded68 + / 0 −
@@ -0,0 +1,68 @@+import path from 'path'+import type { webpack } from 'next/dist/compiled/webpack/webpack'+import { writeFileAtomic } from '../../lib/fs/write-atomic'++type OutputFileSystem = NonNullable<webpack.Compiler['outputFileSystem']>+type WriteFile = OutputFileSystem['writeFile']+type WriteCallback = Parameters<WriteFile>[2]++const MANIFEST_FILE_RE = /manifest\.(?:js|json)$/i++/**+ * Whether the dev server reads this emitted file outside of webpack, e.g.+ * through `loadManifest` / `evalManifest`. `build-manifest.json`,+ * `next-font-manifest.json`, `page_client-reference-manifest.js` and so on.+ */+export function isManifestFile(filePath: string): boolean {+  return MANIFEST_FILE_RE.test(path.basename(filePath))+}++/**+ * Returns a webpack output file system that writes manifest files atomically.+ *+ * Webpack writes emitted assets in place, truncating the file first. In dev,+ * a request that renders an already-built route (it doesn't wait for an+ * in-flight rebuild) can read a manifest while it is being rewritten. It then+ * sees an empty or partial file and fails with `Manifest file is empty` or+ * `Unexpected end of JSON input`. Writing manifests with `writeFileAtomic`+ * (temp file + rename, the same helper Turbopack's manifests use) means a+ * reader sees either the old or the new manifest.+ *+ * Manifests are written straight to disk with Node's `fs`, bypassing the given+ * file system's `writeFile`. That is intended for Next.js's disk-backed dev output: the+ * dev server reads these manifests from disk with Node's `fs` anyway, and+ * Next.js doesn't set a custom output file system.+ *+ * The returned object inherits every other method from `fs`, which is left+ * untouched. Webpack shares one file system object (graceful-fs) between all+ * compilers, so it must not be patched in place.+ */+export function createAtomicManifestOutputFileSystem(+  fs: OutputFileSystem+): OutputFileSystem {+  const writeFile = fs.writeFile.bind(fs)++  const atomicWriteFile = ((+    filePath: string,+    content: string | Buffer,+    callback: WriteCallback+  ) => {+    if (!isManifestFile(filePath)) {+      return writeFile(filePath, content, callback)+    }++    let error: NodeJS.ErrnoException | null = null+    try {+      writeFileAtomic(filePath, content)+    } catch (err) {+      error = err as NodeJS.ErrnoException+    }+    // Call back asynchronously, like `fs.writeFile`, and outside the `try` so+    // an exception thrown by the callback doesn't call it a second time.+    process.nextTick(callback, error)+  }) as WriteFile++  const wrapped = Object.create(fs) as OutputFileSystem+  wrapped.writeFile = atomicWriteFile+  return wrapped+}
packages/next/src/server/dev/hot-reloader-webpack.ts10 + / 0 −
@@ -111,6 +111,7 @@ import { getMcpMiddleware } from '../mcp/get-mcp-middleware' import { setStackFrameResolver } from '../mcp/tools/utils/format-errors' import { recordMcpTelemetry } from '../mcp/mcp-telemetry-tracker' import { getFileLogger } from './browser-logs/file-logger'+import { createAtomicManifestOutputFileSystem } from './atomic-manifest-output-fs' import type { ServerCacheStatus } from '../../next-devtools/dev-overlay/cache-indicator' import type { Lockfile } from '../../build/lockfile' import {@@ -845,6 +846,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {     })      const fallbackCompiler = getWebpackBundler()(fallbackConfig)+    // Requests can read manifests while a rebuild is emitting them.+    fallbackCompiler.outputFileSystem = createAtomicManifestOutputFileSystem(+      fallbackCompiler.outputFileSystem!+    )      this.fallbackWatcher = await new Promise((resolve) => {       let bootedFallbackCompiler = false@@ -1278,8 +1283,13 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {      // Copy over the filesystem so that it is shared between all compilers.     const inputFileSystem = this.multiCompiler.compilers[0].inputFileSystem+    // Requests can read manifests while a rebuild is emitting them.+    const outputFileSystem = createAtomicManifestOutputFileSystem(+      this.multiCompiler.compilers[0].outputFileSystem!+    )     for (const compiler of this.multiCompiler.compilers) {       compiler.inputFileSystem = inputFileSystem+      compiler.outputFileSystem = outputFileSystem       // This is set for the initial compile. After that Watching class in webpack adds it.       compiler.fsStartTime = Date.now()       // Ensure NodeEnvironmentPlugin doesn't purge the inputFileSystem. Purging is handled in `done` below.