Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .agents/skills/gate-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ Every name in a pragma must be declared in `test/lib/gate/conditions.ts`
(typos fail the suite at collection). Two tiers:

- **static** — the run's shape: `dev`, `start`, `deploy`, `mode`, `turbopack`,
`rspack`, `webpack`, `bundler`, `react18`, `wasm`, `ci`; specialized CI
variants `adapter`, `standaloneOutput`, `turbopackDev`, and `turbopackBuild`;
plus the always-false `FIXME`/`TODO`.
`rspack`, `webpack`, `bundler`, `react18`, `wasm`, `linux`, `macos`,
`windows`, `ci`; specialized CI variants `adapter`, `standaloneOutput`,
`turbopackDev`, and `turbopackBuild`; plus the always-false `FIXME`/`TODO`.
`prod` and `prefetching` are semantic aliases for `!dev` — prefer the name
that states _why_ the suite cannot run.
- **lazy** — a predicate over the fixture's _resolved_ `next.config`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,19 @@ module.exports = {
}
```

To resolve files from linked dependencies outside the project root (via `npm link`, `yarn link`, `pnpm link`, etc.), you must either configure the `turbopack.root` to the parent directory of both the project and the linked dependencies or configure an additional root.
To resolve files from linked dependencies outside the project root (e.g. [`npm link`]), you must either configure the `turbopack.root` to the parent directory of both the project and the linked dependencies or configure an additional root.

Do not extend the root directory too broadly (e.g. to your home directory). Turbopack may watch all files inside of roots, and configuring too broad of a root directory can cause significant performance problems.
Use this option to configure the root when working in a monorepo. Do not extend the root directory too broadly (e.g. to your home directory). Turbopack may watch all files inside of roots, and configuring too broad of a root directory can cause significant performance problems.

[`npm link`]: https://docs.npmjs.com/cli/commands/npm-link

### Additional roots (experimental)

`experimental.turbopackAdditionalRoots` allows Turbopack to follow symbolic links whose targets are outside `turbopack.root`. It is useful for linked packages that live outside the project.
`experimental.turbopackAdditionalRoots` allows Turbopack to follow symbolic links whose targets are outside `turbopack.root`.

It is useful for linked packages that live outside the project (e.g. [`npm link`](https://docs.npmjs.com/cli/commands/npm-link) or a [global virtual store](https://pnpm.io/global-virtual-store)).

Additional roots are read-only. Additional roots can only be crossed at symlink boundaries. Direct relative imports that escape a root are unsupported.
Additional roots can only be crossed at symlink boundaries. Direct relative imports that escape a root are unsupported.

```js filename="next.config.js"
const path = require('path')
Expand All @@ -136,7 +140,13 @@ module.exports = {

Each key identifies one root. `path` can be absolute or relative to the directory where you run Next.js.

If a root path is not found when `next build` or `next dev` starts, a warning will be issued, unless `ignoreIfMissing` is set to `true` (defaults to false).
> **Good to know**:
>
> - The symbolic link and its target must both be available on the build machine before `next dev` or `next build` starts.
> - Deployment tools and CI systems may copy your repository to a separate build machine. These tools may be unaware of the additional roots, and they may fail to copy required files.
> - On Windows, builds [require Developer Mode or elevated permissions to create symbolic links](https://blogs.windows.com/windowsdeveloper/2016/12/02/symlinks-windows-10/). Creating output artifacts may require creating symbolic links. In some cases, Turbopack can fall back to [directory junctions](https://learn.microsoft.com/en-us/windows/win32/fileio/hard-links-and-junctions#junctions) when symlink creation is not permitted, but these junctions use absolute paths, so the resulting output may not be relocatable.

If a root path is not found when `next build` or `next dev` starts, a warning will be issued, unless `ignoreIfMissing` is set to `true` (defaults to `false`).

To ensure portability of your configuration across a variety of platforms, including Windows, a root name must meet all of these requirements:

Expand All @@ -145,9 +155,9 @@ To ensure portability of your configuration across a variety of platforms, inclu
- It is not a Windows device name.
- It does not duplicate an earlier name under ASCII case-insensitive comparison.

To avoid ambiguous references to files, roots may not contain overlapping paths. For example, configuring both `foo/` and `foo/bar/` as roots would produce a warning, and the last-configured value will be ignored.
To avoid ambiguous references to files, additional roots may not overlap `turbopack.root` or one another. This means an additional root cannot be inside `turbopack.root` or contain it. For example, configuring both `foo/` and `foo/bar/` as additional roots would produce a warning, and the overlapping additional root will be ignored.

Additional roots changes the [`trace format`](/docs/app/api-reference/config/next-config-js/output#turbopack-nft-extensions) and requires support from the [build adapter](/docs/app/api-reference/adapters).
Additional roots change Next.js's [`trace format`](/docs/app/api-reference/config/next-config-js/output#turbopack-nft-extensions).

When using [`output: "standalone"`](/docs/app/api-reference/config/next-config-js/output#automatically-copying-traced-files), unbundled files stored in these additional roots will be copied to a subdirectory of the output directory containing the configured name of the root.

Expand Down
59 changes: 47 additions & 12 deletions packages/next/src/build/adapter/build-complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ import { resolveCacheHandlerPathToFilesystem } from '../../lib/format-dynamic-im
import { InvariantError } from '../../shared/lib/invariant-error'
import type { __ApiPreviewProps } from '../../server/api-utils'
import { mapNftFileEntries, type NftJson } from '../nft'
import {
createAdapterSyntheticSymlinkDirectory,
type SyntheticSymlinkManager,
} from './synthetic-symlinks'

interface SharedRouteFields {
/**
Expand Down Expand Up @@ -654,6 +658,7 @@ export async function handleBuildComplete({
) as NextAdapter

if (typeof adapterMod.onBuildComplete === 'function') {
const syntheticSymlinks = createAdapterSyntheticSymlinkDirectory(distDir)
const outputs: AdapterOutputs = {
pages: [],
pagesApi: [],
Expand Down Expand Up @@ -725,6 +730,7 @@ export async function handleBuildComplete({
bundler,
hasInstrumentationHook,
config,
syntheticSymlinks,
})

async function handleTraceFiles(
Expand All @@ -737,7 +743,9 @@ export async function handleBuildComplete({
assets,
assetsHashes,
repoRoot,
`${entryFilePath}.nft.json`
`${entryFilePath}.nft.json`,
syntheticSymlinks,
config.outputHashSalt || ''
)
Object.assign(
assets,
Expand Down Expand Up @@ -2490,6 +2498,7 @@ async function getSharedNodeAssets({
requiredServerFiles,
hasInstrumentationHook,
config,
syntheticSymlinks,
}: {
dir: string
bundler: Bundler
Expand All @@ -2499,6 +2508,7 @@ async function getSharedNodeAssets({
requiredServerFiles: string[]
hasInstrumentationHook: boolean
config: NextConfigComplete
syntheticSymlinks: SyntheticSymlinkManager
}) {
const sharedNodeAssets: Record<string, string> = {}
const sharedNodeAssetsHashes: Record<string, string> = {}
Expand Down Expand Up @@ -2699,7 +2709,9 @@ async function getSharedNodeAssets({
sharedNodeAssets,
sharedNodeAssetsHashes,
repoRoot,
path.join(distDir, 'server', 'instrumentation.js.nft.json')
path.join(distDir, 'server', 'instrumentation.js.nft.json'),
syntheticSymlinks,
salt
)

const fileOutputPath = path.relative(
Expand Down Expand Up @@ -2764,38 +2776,61 @@ async function loadNFT(
assets: Record<string, string>,
assetsHashes: Record<string, string>,
repoRoot: string,
traceFilePath: string
traceFilePath: string,
syntheticSymlinks: SyntheticSymlinkManager,
salt: string
): Promise<{ entryHash?: string }> {
const nft = JSON.parse(await fs.readFile(traceFilePath, 'utf8')) as NftJson

// This call site only records source locations and hashes, so it does not need
// the mapped symlink targets.
for (const entry of mapNftFileEntries(nft, traceFilePath, repoRoot)) {
assets[entry.destination] = entry.source
if (entry.hash) {
assetsHashes[entry.destination] = entry.hash
let source = entry.source
let hash = entry.hash

if (entry.symlinkCrossesRoot) {
if (entry.symlinkTarget === undefined) {
throw new InvariantError(
`Expected cross-root symlink ${JSON.stringify(entry.destination)} to have a target`
)
}
const linkTarget =
path.relative(path.dirname(entry.destination), entry.symlinkTarget) ||
'.'
hash = hashLinkTarget(salt, linkTarget)
source = syntheticSymlinks.createLink(entry.source, linkTarget, hash)
}

assets[entry.destination] = source
if (hash) {
assetsHashes[entry.destination] = hash
}
}
return { entryHash: nft.entryHash }
}

async function hashFile(salt: string, filePath: string): Promise<string> {
const hash = crypto.createHash('sha256')
hash.update(salt)
try {
// Try symlink first, since readFile just transparently resolves those (or fails if it's a
// directory symlink).
const linkTarget = await fs.readlink(filePath)
hash.update('link')
hash.update(linkTarget)
return hashLinkTarget(salt, linkTarget)
} catch (e: any) {
if (e.code === 'EINVAL') {
// Not a symlink
const hash = crypto.createHash('sha256')
hash.update(salt)
hash.update('file:')
hash.update(await fs.readFile(filePath))
return hash.digest('hex')
} else {
throw e
}
}
}

function hashLinkTarget(salt: string, linkTarget: string): string {
const hash = crypto.createHash('sha256')
hash.update(salt)
hash.update('link')
hash.update(linkTarget)
return hash.digest('hex')
}
56 changes: 56 additions & 0 deletions packages/next/src/build/adapter/synthetic-symlinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import fs from 'fs/promises'
import os from 'os'
import path from 'path'
import { createAdapterSyntheticSymlinkDirectory } from './synthetic-symlinks'

describe('adapter synthetic symlinks', () => {
// @force-gate !windows
describe('SyntheticSymlinkManager', () => {
let testDirectory: string

beforeEach(async () => {
testDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), 'next-adapter-symlinks-')
)
})

afterEach(async () => {
await fs.rm(testDirectory, {
recursive: true,
force: true,
maxRetries: 3,
})
})

it('cleans stale staging and reuses the staged path', async () => {
const distDir = path.join(testDirectory, '.next')
const stagingRoot = path.join(distDir, 'adapter', 'synthetic_symlinks')
const staleFile = path.join(stagingRoot, 'stale')
await fs.mkdir(stagingRoot, { recursive: true })
await fs.writeFile(staleFile, 'stale')

const manager = createAdapterSyntheticSymlinkDirectory(distDir)
await expect(fs.access(staleFile)).rejects.toMatchObject({
code: 'ENOENT',
})

const source = path.join(testDirectory, 'source-link')
const linkTarget = path.relative(
path.join('functions', 'app', 'node_modules'),
path.join('next_additional_roots', 'packages', 'pkg')
)
const targetHash = 'a'.repeat(64)
const first = manager.createLink(source, linkTarget, targetHash)
const second = manager.createLink(
path.join(testDirectory, 'equivalent-source-link'),
linkTarget,
targetHash
)

expect(path.dirname(first)).toBe(stagingRoot)
expect(path.basename(first)).toBe('a'.repeat(32))
expect(second).toBe(first)
expect(await fs.readlink(first)).toBe(linkTarget)
})
})
})
73 changes: 73 additions & 0 deletions packages/next/src/build/adapter/synthetic-symlinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import fs from 'fs'
import path from 'path'

type SymlinkTargetType = 'file' | 'dir'

/**
* When passing cross-root symlinks from Turbopack's additional roots feature to
* the adapter, we may need to rewrite the symlink target's relative path.
*
* Adapters receive a map of `{"destination": "source"}` file paths, and they
* copy symlink paths verbatim.
*
* We must write a "synthetic" symlink at a source path (inside
* `.next/adapter/synthetic_symlinks`) for the adapter to copy to its output
* artifact (e.g. a Lambda zip file).
*
* As a future optimization, we could pass symlink information directly to the
* adapter, if the adapter signals that it supports accepting that information.
* That would avoid a lot of small filesystem operations.
*/
export class SyntheticSymlinkManager {
private readonly stagedLinkNames = new Set<string>()

constructor(private readonly stagingRoot: string) {}

createLink(source: string, linkTarget: string, targetHash: string): string {
let targetType: SymlinkTargetType = 'file'
// Keep 128 bits of entropy while shortening the path to avoid Windows'
// 260-character path limit.
let stagedName = targetHash.slice(0, 32)

if (process.platform === 'win32') {
try {
targetType = fs.statSync(source).isDirectory() ? 'dir' : 'file'
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
// We cannot determine the target type, so just create a file symlink
// ENOENT: Dangling link, preserve the dangling link as a file link
// ELOOP: Unresolvable link cycle, preserve any part of the cycle that
// was traced
if (code !== 'ENOENT' && code !== 'ELOOP') {
Comment thread
bgw marked this conversation as resolved.
throw error
}
}
stagedName += `_${targetType}`
}

const stagedPath = path.join(this.stagingRoot, stagedName)
if (!this.stagedLinkNames.has(stagedName)) {
try {
fs.symlinkSync(linkTarget, stagedPath, targetType)
} catch (error) {
// This link may exist if `rmSync` (with `force: true`) failed to delete
// some files (can happen on Windows), but it's content-addressed, so
// we can safely ignore EEXIST.
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error
}
}
this.stagedLinkNames.add(stagedName)
}
return stagedPath
}
}

export function createAdapterSyntheticSymlinkDirectory(
distDir: string
): SyntheticSymlinkManager {
const stagingRoot = path.join(distDir, 'adapter', 'synthetic_symlinks')
fs.rmSync(stagingRoot, { recursive: true, force: true, maxRetries: 3 })
fs.mkdirSync(stagingRoot, { recursive: true })
return new SyntheticSymlinkManager(stagingRoot)
}
62 changes: 62 additions & 0 deletions packages/next/src/build/nft.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import path from 'path'
import { mapNftFileEntries, type NftJson } from './nft'

describe('mapNftFileEntries', () => {
it('marks only symlinks whose tuple specifies another root', () => {
const traceFilePath = path.join(
path.parse(process.cwd()).root,
'repo',
'.next',
'server',
'page.js.nft.json'
)
const repoRoot = path.join(path.parse(traceFilePath).root, 'repo')
const nft: NftJson = {
version: 1,
files: ['same-root-link', 'root-zero-link', 'base-root-link'],
symlinks: [
[0, 'same-root-target'],
[1, 'root-zero-target', 0],
[2, 'base-root-target', -1],
],
additionalRoots: [
{
name: 'root-zero',
path: '../../../external',
files: [],
symlinks: [],
},
],
}

const [sameRoot, rootZero, baseRoot] = mapNftFileEntries(
nft,
traceFilePath,
repoRoot
)

expect(sameRoot).toEqual(
expect.objectContaining({
symlinkTarget: path.join('.next', 'server', 'same-root-target'),
})
)
expect(sameRoot.symlinkCrossesRoot).toBe(false)

expect(rootZero).toEqual(
expect.objectContaining({
symlinkTarget: path.join(
'next_additional_roots',
'root-zero',
'root-zero-target'
),
symlinkCrossesRoot: true,
})
)
expect(baseRoot).toEqual(
expect.objectContaining({
symlinkTarget: path.join('.next', 'server', 'base-root-target'),
symlinkCrossesRoot: true,
})
)
})
})
Loading
Loading