Files
vercel__next.js/test/development/basic/next-rs-api.test.ts
Benjamin Woodruff ‮ 7b58e5880c Turbopack: Add support for specifying additional roots (#98003)
Full motivation and plan here:
https://app.notion.com/p/vercel/Turbopack-pnpm-Global-Virtual-Store-383e06b059c480579403ddfd71cc2d40?source=copy_link

The goal is to allow `DiskFileSystem` to traverse outside of it's own
root to other configured `DiskFileSystem`s when following symlinks. We
may allow traversal in other situations in the future, but this is
limited to symlink resolution for now.

## Global Virtual Store

The motivation for this is to enable [pnpm's Global Virtual Store
feature](https://pnpm.io/global-virtual-store) (and there are other
package managers doing this, including nub and bun).

We'd expose the ability to manually configure this in `next.config.js`,
but we should also auto-configure ourselves for popular package managers
(or at least make a best effort to do so, the `PNPM_HOME` semantics can
be complicated). The `ignoreIfMissing` option is provided for this
situation: We can configure a bunch of roots automatically, and they
only actually get set up if they exist, the check for directory
existence is cheap.

## NFT changes

This requires a couple extensions to the `*.nft.json` file format:
https://github.com/vercel/next.js/pull/98469

## Related Issues

- #93556
- https://github.com/pnpm/pnpm/issues/14972
2026-09-18 17:08:40 -07:00

798 lines
25 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { PHASE_DEVELOPMENT_SERVER } from 'next/constants'
import { createDefineEnv, loadBindings } from 'next/dist/build/swc'
import type {
Issue,
MemoryEvictionMode,
Project,
RawEntrypoints,
StyledString,
TurbopackResult,
UpdateInfo,
} from 'next/dist/build/swc/types'
import loadConfig from 'next/dist/server/config'
import path, { join } from 'path'
import { spawnSync } from 'child_process'
function normalizePath(path: string) {
return path
.replace(/\[project\].+\/node_modules\//g, '[project]/.../node_modules/')
.replace(
/\[project\]\/packages\/next\//g,
'[project]/.../node_modules/next/'
)
}
function styledStringToMarkdown(styled: StyledString): string {
switch (styled.type) {
case 'text':
return styled.value
case 'strong':
return '**' + styled.value + '**'
case 'code':
return '`' + styled.value + '`'
case 'line':
return styled.value.map(styledStringToMarkdown).join('')
case 'stack':
return styled.value.map(styledStringToMarkdown).join('\n')
default:
throw new Error('Unknown StyledString type', styled)
}
}
function normalizeIssues(issues: Issue[]) {
return issues
.map((issue) => ({
...issue,
detail:
issue.detail && normalizePath(styledStringToMarkdown(issue.detail)),
filePath: issue.filePath && normalizePath(issue.filePath),
source: issue.source && {
...issue.source,
source: normalizePath(issue.source.source.ident),
},
}))
.sort((a, b) => {
const a_ = JSON.stringify(a)
const b_ = JSON.stringify(b)
if (a_ < b_) return -1
if (a_ > b_) return 1
return 0
})
}
function raceIterators<T>(iterators: AsyncIterableIterator<T>[]) {
const nexts = iterators.map((iterator, i) =>
iterator.next().then((next) => ({ next, i }))
)
return (async function* () {
while (true) {
const remaining = nexts.filter((x) => x)
if (remaining.length === 0) return
const { next, i } = await Promise.race(remaining)
if (!next.done) {
yield next.value
nexts[i] = iterators[i].next().then((next) => ({ next, i }))
} else {
nexts[i] = undefined
}
}
})()
}
async function* filterMapAsyncIterator<T, U>(
iterator: AsyncIterableIterator<T>,
transform: (t: T) => U | undefined
): AsyncGenerator<Awaited<U>> {
for await (const val of iterator) {
const mapped = transform(val)
if (mapped !== undefined) {
yield mapped
}
}
}
/**
* Drains the stream until no value is available for 100ms, then returns the next value.
*/
async function drainAndGetNext<T>(stream: AsyncIterableIterator<T>) {
while (true) {
const next = stream.next()
const result = await Promise.race([
new Promise((r) => setTimeout(() => r({ next }), 100)),
next.then(() => undefined),
])
if (result) return result
}
}
function pagesIndexCode(text, props = {}) {
return `import props from "../lib/props.js";
export default () => <div>${text}</div>;
export function getServerSideProps() { return { props: { ...props, ...${JSON.stringify(
props
)}} } }`
}
function appPageCode(text) {
return `import Client from "./client.tsx";
export default () => <div>${text}<Client /></div>;`
}
describe('next.rs api writeToDisk multiple times', () => {
const { next } = nextTestSetup({
skipStart: true,
files: {
'pages/index.js': pagesIndexCode('hello world'),
'lib/props.js': 'export default {}',
'pages/page-nodejs.js': 'export default () => <div>hello world</div>',
'pages/page-edge.js':
'export default () => <div>hello world</div>\nexport const config = { runtime: "experimental-edge" }',
'pages/api/nodejs.js':
'export default () => Response.json({ hello: "world" })',
'pages/api/edge.js':
'export default () => Response.json({ hello: "world" })\nexport const config = { runtime: "edge" }',
'app/layout.tsx':
'export default function RootLayout({ children }: { children: any }) { return (<html><body>{children}</body></html>)}',
'app/loading.tsx':
'export default function Loading() { return <>Loading</> }',
'app/app/page.tsx': appPageCode('hello world'),
'app/app/client.tsx':
'"use client";\nexport default () => <div>hello world</div>',
'app/app-edge/page.tsx':
'export default () => <div>hello world</div>\nexport const runtime = "edge"',
'app/app-nodejs/page.tsx': 'export default () => <div>hello world</div>',
'app/route-nodejs/route.ts':
'export function GET() { return Response.json({ hello: "world" }) }',
'app/route-edge/route.ts':
'export function GET() { return Response.json({ hello: "world" }) }\nexport const runtime = "edge"',
'server.js': `
process.title = 'next.rs api run test';
const path = require('path');
const { PHASE_DEVELOPMENT_SERVER } = require('next/constants');
const loadConfig = require('next/dist/server/config');
const { loadBindings } = require('next/dist/build/swc');
const { createDefineEnv } = require('next/dist/build/swc');
async function main() {
const nextConfig = await loadConfig.default(
PHASE_DEVELOPMENT_SERVER,
__dirname
);
const bindings = await loadBindings();
const rootPath = __dirname;
const distDir = '.next';
const projectResult = await bindings.turbo.createProject({
env: {},
nextConfig: nextConfig,
rootPath,
projectPath: '.',
distDir,
watch: {
enable: true,
},
dev: true,
defineEnv: createDefineEnv({
projectPath: __dirname,
isTurbopack: true,
clientRouterFilters: undefined,
config: nextConfig,
dev: true,
distDir: path.join(rootPath, distDir),
fetchCacheKeyPrefix: undefined,
hasRewrites: false,
middlewareMatchers: undefined,
rewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [],
},
}),
buildId: 'development',
encryptionKey: '12345',
previewProps: {
previewModeId: 'development',
previewModeEncryptionKey: '12345',
previewModeSigningKey: '12345',
},
browserslistQuery: 'last 2 versions',
noMangling: false,
writeRoutesHashesManifest: false,
currentNodeJsVersion: '18.0.0',
isPersistentCachingEnabled: false,
nextVersion: '0.0.0',
}, {
turbopackMemoryEviction: 'off',
});
if (projectResult.issues.length > 0) {
throw new Error(
\`Project initialization failed: \${JSON.stringify(projectResult.issues)}\`
);
}
const project = projectResult.value;
const entrypointsSubscription = project.entrypointsSubscribe();
const entrypoints = (await entrypointsSubscription.next()).value.value;
const RUNS = 1000;
async function compileRoute(route) {
const endpoint = route.endpoint ?? route.htmlEndpoint ?? route.pages[0].htmlEndpoint;
if (!endpoint) {
console.log('unexpected route', route);
process.exit(1);
}
for (let i = 0; i < RUNS; i++) {
await endpoint.writeToDisk();
}
}
for (const [name, route] of entrypoints.routes) {
console.log(name, route.type);
console.log('first runs');
await compileRoute(route);
gc(true);
let old = process.memoryUsage();
const initial = old;
let stabilized = false;
for (let j = 0; j < 10; j++) {
await compileRoute(route);
gc(true);
const newMemory = process.memoryUsage();
const addedMemory = newMemory.rss - old.rss;
console.log(\`#\${j} \${RUNS} runs add \${addedMemory} bytes of memory, memory since first runs \${newMemory.rss - initial.rss} bytes\`);
old = newMemory;
if (addedMemory === 0) {
console.log('memory usage stabilized');
stabilized = true;
break;
}
}
if (!stabilized) {
console.log('memory usage did not stabilize, failing');
process.exit(1);
}
}
}
main()
.then(() => {
process.exit(0);
})
.catch((err) => {
console.error(err);
process.exit(1);
});
`,
},
})
it('should allow to write to disk multiple times', async () => {
const result = spawnSync(
'node',
['--expose-gc', join(next.testDir, 'server.js')],
{
stdio: 'inherit',
env: {
...process.env,
},
}
)
expect(result.status).toBe(0)
})
})
describe('next.rs api', () => {
const { next } = nextTestSetup({
skipStart: true,
files: {
'pages/index.js': pagesIndexCode('hello world'),
'lib/props.js': 'export default {}',
'pages/page-nodejs.js': 'export default () => <div>hello world</div>',
'pages/page-edge.js':
'export default () => <div>hello world</div>\nexport const config = { runtime: "experimental-edge" }',
'pages/api/nodejs.js':
'export default () => Response.json({ hello: "world" })',
'pages/api/edge.js':
'export default () => Response.json({ hello: "world" })\nexport const config = { runtime: "edge" }',
'app/layout.tsx':
'export default function RootLayout({ children }: { children: any }) { return (<html><body>{children}</body></html>)}',
'app/loading.tsx':
'export default function Loading() { return <>Loading</> }',
'app/app/page.tsx': appPageCode('hello world'),
'app/app/client.tsx':
'"use client";\nexport default () => <div>hello world</div>',
'app/app-edge/page.tsx':
'export default () => <div>hello world</div>\nexport const runtime = "edge"',
'app/app-nodejs/page.tsx': 'export default () => <div>hello world</div>',
'app/route-nodejs/route.ts':
'export function GET() { return Response.json({ hello: "world" }) }',
'app/route-edge/route.ts':
'export function GET() { return Response.json({ hello: "world" }) }\nexport const runtime = "edge"',
},
})
let project: Project
let projectUpdateSubscription: AsyncIterableIterator<UpdateInfo>
beforeAll(async () => {
const nextConfig = await loadConfig(PHASE_DEVELOPMENT_SERVER, next.testDir)
const bindings = await loadBindings()
const rootPath = process.env.NEXT_SKIP_ISOLATE
? path.resolve(__dirname, '../../..')
: next.testDir
const distDir = '.next'
const projectResult = await bindings.turbo.createProject(
{
env: {},
nextConfig: nextConfig,
rootPath,
projectPath: path.relative(rootPath, next.testDir) || '.',
distDir,
watch: {
enable: true,
},
dev: true,
defineEnv: createDefineEnv({
projectPath: next.testDir,
isTurbopack: true,
clientRouterFilters: undefined,
config: nextConfig,
dev: true,
distDir: path.join(rootPath, distDir),
fetchCacheKeyPrefix: undefined,
hasRewrites: false,
middlewareMatchers: undefined,
rewrites: {
beforeFiles: [],
afterFiles: [],
fallback: [],
},
}),
buildId: 'development',
encryptionKey: '12345',
previewProps: {
previewModeId: 'development',
previewModeEncryptionKey: '12345',
previewModeSigningKey: '12345',
},
browserslistQuery: 'last 2 versions',
noMangling: false,
writeRoutesHashesManifest: false,
currentNodeJsVersion: '18.0.0',
isPersistentCachingEnabled: false,
nextVersion: '0.0.0',
},
{
turbopackMemoryEviction: 'off' as MemoryEvictionMode,
}
)
const initializationIssues = normalizeIssues(projectResult.issues)
if (initializationIssues.length > 0) {
throw new Error(
`Project initialization failed:\n${JSON.stringify(initializationIssues, null, 2)}`
)
}
project = projectResult.value
projectUpdateSubscription = filterMapAsyncIterator(
project.updateInfoSubscribe(1000),
(update) => (update.updateType === 'end' ? update.value : undefined)
)
})
it('should detect the correct routes', async () => {
const entrypointsSubscription = project.entrypointsSubscribe()
const entrypoints = await entrypointsSubscription.next()
expect(entrypoints.done).toBe(false)
if (!('routes' in entrypoints.value.value)) {
throw new Error('Entrypoints not available due to compilation errors')
}
expect(Array.from(entrypoints.value.value.routes.keys()).sort()).toEqual([
'/',
'/_not-found',
'/api/edge',
'/api/nodejs',
'/app',
'/app-edge',
'/app-nodejs',
'/page-edge',
'/page-nodejs',
'/route-edge',
'/route-nodejs',
])
expect(normalizeIssues(entrypoints.value.issues)).toMatchSnapshot('issues')
await entrypointsSubscription.return()
})
const routes = [
{
name: 'root page',
path: '/',
type: 'page',
runtime: 'nodejs',
config: {},
},
{
name: 'pages edge api',
path: '/api/edge',
type: 'page-api',
runtime: 'edge',
config: {},
},
{
name: 'pages Node.js api',
path: '/api/nodejs',
type: 'page-api',
runtime: 'nodejs',
config: {},
},
{
name: 'app edge page',
path: '/app-edge',
type: 'app-page',
runtime: 'edge',
config: {},
},
{
name: 'app Node.js page',
path: '/app-nodejs',
type: 'app-page',
runtime: 'nodejs',
config: {},
},
{
name: 'pages edge page',
path: '/page-edge',
type: 'page',
runtime: 'edge',
config: {},
},
{
name: 'pages Node.js page',
path: '/page-nodejs',
type: 'page',
runtime: 'nodejs',
config: {},
},
{
name: 'app edge route',
path: '/route-edge',
type: 'app-route',
runtime: 'edge',
config: {},
},
{
name: 'app Node.js route',
path: '/route-nodejs',
type: 'app-route',
runtime: 'nodejs',
config: {},
},
]
for (const { name, path, type, runtime, config } of routes) {
// eslint-disable-next-line no-loop-func
it(`should allow to write ${name} to disk`, async () => {
const entrypointsSubscribtion = project.entrypointsSubscribe()
const entrypoints: TurbopackResult<RawEntrypoints | {}> = (
await entrypointsSubscribtion.next()
).value
if (!('routes' in entrypoints.value)) {
throw new Error('Entrypoints not available due to compilation errors')
}
const route = entrypoints.value.routes.get(path)
entrypointsSubscribtion.return()
expect(route.type).toBe(type)
switch (route.type) {
case 'page-api':
case 'app-route': {
const result = await route.endpoint.writeToDisk()
expect(result.value.type).toBe(runtime)
expect(result.value.config).toEqual(config)
expect(normalizeIssues(result.issues)).toMatchSnapshot('issues')
break
}
case 'page': {
const result = await route.htmlEndpoint.writeToDisk()
expect(result.value.type).toBe(runtime)
expect(result.value.config).toEqual(config)
expect(normalizeIssues(result.issues)).toMatchSnapshot('issues')
const result2 = await route.dataEndpoint.writeToDisk()
expect(result2.value.type).toBe(runtime)
expect(result2.value.config).toEqual(config)
expect(normalizeIssues(result2.issues)).toMatchSnapshot('data issues')
break
}
case 'app-page': {
const result = await route.pages[0].htmlEndpoint.writeToDisk()
expect(result.value.type).toBe(runtime)
expect(result.value.config).toEqual(config)
expect(normalizeIssues(result.issues)).toMatchSnapshot('issues')
const result2 = await route.pages[0].rscHmrEndpoint.writeToDisk()
expect(result2.value.type).toBe(runtime)
expect(result2.value.config).toEqual(config)
expect(normalizeIssues(result2.issues)).toMatchSnapshot('rsc issues')
break
}
default: {
throw new Error('unknown route type')
}
}
})
}
const hmrCases: {
name: string
path: string
type: string
file: string
content: string
expectedUpdate: string | false
expectedServerSideChange: boolean
}[] = [
{
name: 'client-side change on a page',
path: '/',
type: 'page',
file: 'pages/index.js',
content: pagesIndexCode('hello world2'),
expectedUpdate: '/pages/index.js',
expectedServerSideChange: false,
},
{
name: 'server-side change on a page',
path: '/',
type: 'page',
file: 'lib/props.js',
content: 'export default { some: "prop" }',
expectedUpdate: false,
expectedServerSideChange: true,
},
{
name: 'client and server-side change on a page',
path: '/',
type: 'page',
file: 'pages/index.js',
content: pagesIndexCode('hello world2', { another: 'prop' }),
expectedUpdate: '/pages/index.js',
expectedServerSideChange: true,
},
{
name: 'client-side change on a app page',
path: '/app',
type: 'app-page',
file: 'app/app/client.tsx',
content: '"use client";\nexport default () => <div>hello world2</div>',
expectedUpdate: '/app/app/client.tsx',
expectedServerSideChange: false,
},
{
name: 'server-side change on a app page',
path: '/app',
type: 'app-page',
file: 'app/app/page.tsx',
content: appPageCode('hello world2'),
expectedUpdate: false,
expectedServerSideChange: true,
},
]
for (const {
name,
path,
type,
file,
content,
expectedUpdate,
expectedServerSideChange,
} of hmrCases) {
for (let i = 0; i < 3; i++)
// eslint-disable-next-line no-loop-func
it(`should have working HMR on ${name} ${i}`, async () => {
console.log('start')
await new Promise((r) => setTimeout(r, 1000))
const entrypointsSubscribtion = project.entrypointsSubscribe()
const entrypoints: TurbopackResult<RawEntrypoints | {}> = (
await entrypointsSubscribtion.next()
).value
if (!('routes' in entrypoints.value)) {
throw new Error('Entrypoints not available due to compilation errors')
}
const route = entrypoints.value.routes.get(path)
entrypointsSubscribtion.return()
expect(route.type).toBe(type)
let serverSideSubscription:
| AsyncIterableIterator<TurbopackResult<void>>
| undefined
switch (route.type) {
case 'page': {
await route.htmlEndpoint.writeToDisk()
serverSideSubscription =
await route.dataEndpoint.serverChanged(false)
break
}
case 'app-page': {
await route.pages[0].htmlEndpoint.writeToDisk()
serverSideSubscription =
await route.pages[0].rscHmrEndpoint.serverChanged(false)
break
}
default: {
throw new Error('unknown route type')
}
}
const result = await project.clientHmrChunkNamesSubscribe().next()
expect(result.done).toBe(false)
const chunkNames = result.value.value.chunkNames
expect(chunkNames).toHaveProperty('length', expect.toBePositive())
const subscriptions = chunkNames.map((chunkName) =>
project.clientHmrEvents(chunkName)
)
await Promise.all(
subscriptions.map(async (subscription) => {
const result = await subscription.next()
expect(result.done).toBe(false)
expect(result.value.value).toHaveProperty(
'resource',
expect.toBeObject()
)
expect(result.value.value).toHaveProperty('type', 'issues')
expect(normalizeIssues(result.value.issues)).toEqual([])
})
)
console.log('waiting for events')
const { next: updateComplete } = await drainAndGetNext(
projectUpdateSubscription
)
const oldContent = await next.readFile(file)
let ok = false
try {
await next.patchFile(file, content)
let foundUpdates: string[] | false = false
let foundServerSideChange = false
let done = false
const result2 = await Promise.race(
[
(async () => {
const merged = raceIterators(subscriptions)
for await (const item of merged) {
if (done) return
if (item.value.type === 'partial') {
expect(item.value.instruction).toEqual({
type: 'ChunkListUpdate',
merged: [
expect.objectContaining({
chunks: expect.toBeObject(),
entries: expect.toBeObject(),
}),
],
})
const updates = Object.keys(
item.value.instruction.merged[0].entries
)
expect(updates).not.toBeEmpty()
foundUpdates = foundUpdates || []
foundUpdates.push(
...Object.keys(item.value.instruction.merged[0].entries)
)
}
}
})(),
serverSideSubscription &&
(async () => {
for await (const { issues } of serverSideSubscription) {
if (done) return
expect(issues).toBeArray()
foundServerSideChange = true
}
})(),
updateComplete.then(
(u) => new Promise((r) => setTimeout(() => r(u), 1000))
),
new Promise((r) => setTimeout(() => r('timeout'), 30000)),
].filter((x) => x)
)
done = true
expect(result2).toMatchObject({
done: false,
value: {
duration: expect.toBePositive(),
tasks: expect.toBePositive(),
},
})
if (typeof expectedUpdate === 'boolean') {
expect(foundUpdates).toBe(false)
} else {
expect(
typeof foundUpdates === 'boolean'
? foundUpdates
: Array.from(new Set(foundUpdates))
).toEqual([expect.stringContaining(expectedUpdate)])
}
expect(foundServerSideChange).toBe(expectedServerSideChange)
ok = true
} finally {
try {
const { next: updateComplete2 } = await drainAndGetNext(
projectUpdateSubscription
)
await next.patchFile(file, oldContent)
await updateComplete2
} catch (e) {
if (ok) throw e
}
}
})
}
it.skip('should allow to make many HMR updates', async () => {
console.log('start')
await new Promise((r) => setTimeout(r, 1000))
const entrypointsSubscribtion = project.entrypointsSubscribe()
const entrypoints: TurbopackResult<RawEntrypoints | {}> = (
await entrypointsSubscribtion.next()
).value
if (!('routes' in entrypoints.value)) {
throw new Error('Entrypoints not available due to compilation errors')
}
const route = entrypoints.value.routes.get('/')
entrypointsSubscribtion.return()
if (route.type !== 'page') throw new Error('unknown route type')
await route.htmlEndpoint.writeToDisk()
const result = await project.clientHmrChunkNamesSubscribe().next()
expect(result.done).toBe(false)
const chunkNames = result.value.value.chunkNames
const subscriptions = chunkNames.map((chunkName) =>
project.clientHmrEvents(chunkName)
)
await Promise.all(
subscriptions.map(async (subscription) => {
const result = await subscription.next()
expect(result.done).toBe(false)
expect(result.value.value).toHaveProperty(
'resource',
expect.toBeObject()
)
expect(result.value.value).toHaveProperty('type', 'issues')
})
)
const merged = raceIterators(subscriptions)
const file = 'pages/index.js'
let currentContent = await next.readFile(file)
let nextContent = pagesIndexCode('hello world2')
const count = process.env.NEXT_TEST_CI ? 300 : 1000
for (let i = 0; i < count; i++) {
await next.patchFile(file, nextContent)
const content = currentContent
currentContent = nextContent
nextContent = content
while (true) {
const { value, done } = await merged.next()
expect(done).toBe(false)
if (value.value.type === 'partial') {
break
}
}
}
}, 300000)
})