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
17 changes: 17 additions & 0 deletions packages/pack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,23 @@ Node.js, or library builds.
}
```

### Worktree and Symlink Debugging

Set `UTOO_ROOT_PATH` to a common parent containing the project and the real paths
of linked dependencies when debugging worktrees or symlinks:

```bash
UTOO_ROOT_PATH=/path/to/common-parent utx up dev
# Relative paths resolve from the current working directory:
UTOO_ROOT_PATH=.. utx up build
```

Precedence is `UTOO_ROOT_PATH` > explicit `rootPath` > automatic detection. Empty
values are ignored. This applies to `build`, `serve`, and direct project creation,
where `rootPath` can be omitted. Updates that omit it retain the existing root.
`findRootDir()` and the project directory stay unchanged, preserving framework
plugin lookup and generated file locations.

## 🛠️ Development

### Prerequisites
Expand Down
12 changes: 12 additions & 0 deletions packages/pack/src/__test__/projectRootPath.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { projectFactory } from "../core/project";
import type { ProjectOptions } from "../core/types";

declare const options: ProjectOptions;

// Existing consumers can keep reading a required string from ProjectOptions.
export const rootPath: string = options.rootPath;

// Only project creation accepts an omitted root for automatic discovery.
declare const withoutRoot: Omit<Required<ProjectOptions>, "rootPath">;
export const creationOptions: Parameters<ReturnType<typeof projectFactory>>[0] =
withoutRoot;
127 changes: 127 additions & 0 deletions packages/pack/src/__test__/projectRootPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import fs from "fs";
import os from "os";
import path from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const bindingMocks = vi.hoisted(() => ({
projectNew: vi.fn(),
projectUpdate: vi.fn(),
registerWorkerScheduler: undefined,
}));

vi.mock("../binding", () => bindingMocks);

import { projectFactory } from "../core/project";
import { findRootDir } from "../utils/findRoot";
import { normalizePath } from "../utils/normalizePath";

const nativeProject = { __napiType: "Project" as const };
let fixture: string;
let projectPath: string;

beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("UTOO_ROOT_PATH", undefined);
bindingMocks.projectNew.mockResolvedValue(nativeProject);
bindingMocks.projectUpdate.mockResolvedValue(undefined);
fixture = fs.mkdtempSync(path.join(os.tmpdir(), "utoo-project-root-"));
projectPath = path.join(fixture, "worktree", "app");
fs.mkdirSync(projectPath, { recursive: true });
fs.writeFileSync(path.join(fixture, "package-lock.json"), "{}");
});

afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(fixture, { recursive: true, force: true });
});

function createProject(rootPath?: string) {
return projectFactory()(
{
projectPath,
rootPath,
config: { entry: [] },
processEnv: {},
watch: { enable: false },
dev: false,
buildId: "root-path-test",
tracing: false,
packPath: fixture,
},
{},
);
}

function expectNativeRoot(rootPath: string) {
expect(bindingMocks.projectNew.mock.calls[0][0]).toMatchObject({
projectPath: normalizePath(projectPath),
rootPath: normalizePath(rootPath),
});
}

describe("project root configuration", () => {
it.each(["absolute", "relative"])(
"applies an %s environment root without changing framework discovery",
async (kind) => {
const accessRoot = path.dirname(fixture);
vi.stubEnv(
"UTOO_ROOT_PATH",
kind === "absolute"
? accessRoot
: path.relative(process.cwd(), accessRoot),
);
await createProject(projectPath);

expectNativeRoot(accessRoot);
expect(findRootDir(projectPath)).toBe(fixture);
},
);

it.each([undefined, ""])(
"preserves explicit roots and discovers omitted roots when the environment is %s",
async (value) => {
vi.stubEnv("UTOO_ROOT_PATH", value);
await createProject(projectPath);
expectNativeRoot(projectPath);
bindingMocks.projectNew.mockClear();

await createProject();
expectNativeRoot(fixture);
},
);

it("preserves the existing root during config-only updates", async () => {
const project = await createProject(projectPath);
vi.stubEnv("UTOO_ROOT_PATH", fixture);

await project.update({ config: { entry: [] } });

expect(bindingMocks.projectUpdate).toHaveBeenCalledExactlyOnceWith(
nativeProject,
expect.objectContaining({ rootPath: undefined }),
);
});

it("applies the environment override to explicit root updates", async () => {
const project = await createProject(projectPath);
vi.stubEnv("UTOO_ROOT_PATH", fixture);

await project.update({ rootPath: projectPath });

expect(bindingMocks.projectUpdate).toHaveBeenCalledExactlyOnceWith(
nativeProject,
expect.objectContaining({ rootPath: normalizePath(fixture) }),
);
});

it("preserves explicit root updates without an environment override", async () => {
const project = await createProject(projectPath);

await project.update({ rootPath: fixture });

expect(bindingMocks.projectUpdate).toHaveBeenCalledExactlyOnceWith(
nativeProject,
expect.objectContaining({ rootPath: normalizePath(fixture) }),
);
});
});
7 changes: 2 additions & 5 deletions packages/pack/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
isTruthyEnv,
normalizeTurbopackMemoryEviction,
} from "../utils/env";
import { findRootDir } from "../utils/findRoot";
import { resolveRootPath } from "../utils/findRoot";
import { HtmlGenerationManager } from "../utils/HtmlGenerationManager";
import { processHtmlEntry } from "../utils/htmlEntry";
import { acquirePersistentCacheLock } from "../utils/lockfile";
Expand All @@ -37,10 +37,7 @@ export function build(
) {
const bundleOptions = resolveBundleOptions(options, projectPath, rootPath);

if (!rootPath) {
// help user to find the rootDir automatically.
rootPath = findRootDir(projectPath || process.cwd());
}
rootPath = resolveRootPath(projectPath, rootPath);
return buildInternal(bundleOptions, projectPath, rootPath);
}

Expand Down
6 changes: 2 additions & 4 deletions packages/pack/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { createHttpProxyMiddleware } from "../core/proxyHono";
import type { Project } from "../core/types";
import { getOutputPath } from "../utils/cleanOutput";
import { blockStdout, getPackPath } from "../utils/common";
import { findRootDir } from "../utils/findRoot";
import { resolveRootPath } from "../utils/findRoot";
import { createSelfSignedCertificate } from "../utils/mkcert";
import { printServerInfo } from "../utils/printServerInfo";
import { xcodeProfilingReady } from "../utils/xcodeProfile";
Expand Down Expand Up @@ -286,9 +286,7 @@ export function serve(
): Promise<DevServerHandle | void> {
const bundleOptions = resolveBundleOptions(options, projectPath, rootPath);

if (!rootPath) {
rootPath = findRootDir(projectPath || process.cwd());
}
rootPath = resolveRootPath(projectPath, rootPath);
return runDev(bundleOptions, projectPath, rootPath, serverOptions).then(
(handle) => {
if (serverOptions?.returnHandle) return handle;
Expand Down
17 changes: 13 additions & 4 deletions packages/pack/src/core/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
TurbopackRuleConfigItem,
} from "../config/types";
import { getPackPath, rustifyEnv } from "../utils/common";
import { resolveRootPath } from "../utils/findRoot";
import { normalizePath } from "../utils/normalizePath";
import { DiagnosticSources } from "./diagnosticSources";
import { runLoaderWorkerPool } from "./loaderWorkerPool";
Expand All @@ -31,6 +32,9 @@ import {
Update,
} from "./types";

type ProjectCreationOptions = Omit<Required<ProjectOptions>, "rootPath"> &
Partial<Pick<ProjectOptions, "rootPath">>;

/**
* An error caused by a bug in Turbopack, and not the user's code (e.g. a Rust panic). These should
* be written to a log file and details should not be shown to the user.
Expand Down Expand Up @@ -384,7 +388,10 @@ async function rustifyPartialProjectOptions(
): Promise<NapiPartialProjectOptions> {
return {
...options,
rootPath: normalizePathOption(options.rootPath),
rootPath:
options.rootPath === undefined
? undefined
: normalizePath(resolveRootPath(options.projectPath, options.rootPath)),
projectPath: normalizePathOption(options.projectPath),
packPath: normalizePathOption(options.packPath),
config:
Expand All @@ -407,11 +414,13 @@ function normalizePathOption(pathLike: string | undefined): string | undefined {
}

async function rustifyProjectOptions(
options: Required<ProjectOptions>,
options: ProjectCreationOptions,
): Promise<NapiProjectOptions> {
return {
...options,
rootPath: normalizePath(options.rootPath),
rootPath: normalizePath(
resolveRootPath(options.projectPath, options.rootPath),
),
projectPath: normalizePath(options.projectPath),
packPath: normalizePath(options.packPath),
config: await serializeConfig(options.config, options.dev),
Expand Down Expand Up @@ -767,7 +776,7 @@ export function projectFactory(endpointWatchOptions?: {
}

return async function createProject(
options: Required<ProjectOptions>,
options: ProjectCreationOptions,
turboEngineOptions: binding.NapiTurboEngineOptions,
) {
return new ProjectImpl(
Expand Down
2 changes: 2 additions & 0 deletions packages/pack/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export interface ProjectOptions extends BundleOptions {
/**
* A root path from which all files must be nested under. Trying to access
* a file outside this root will fail. Think of this as a chroot.
* Defaults to automatic root detection from projectPath. UTOO_ROOT_PATH
* overrides both explicit paths and automatic detection.
*/
rootPath: string;

Expand Down
14 changes: 14 additions & 0 deletions packages/pack/src/utils/findRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import findUp from "find-up";
import fs from "fs";
import path from "path";

export function resolveRootPath(
projectPath?: string,
rootPath?: string,
): string {
const envRootPath = process.env.UTOO_ROOT_PATH;
return envRootPath
? path.resolve(envRootPath)
: rootPath || findRootDir(projectPath || process.cwd());
}

export function findRootLockFile(cwd: string) {
return findUp.sync(
[
Expand Down Expand Up @@ -55,6 +65,10 @@ export function findWorkspacesRoot(cwd: string) {
return path.dirname(pkgJsonFiles[pkgJsonFiles.length - 1]);
}

/**
* Discover the existing project/workspace root for framework configuration.
* UTOO_ROOT_PATH overrides the bundler's access root, not this directory.
*/
export function findRootDir(cwd: string): string {
const lockFile = findRootLockFile(cwd);
if (!lockFile) return findWorkspacesRoot(cwd);
Expand Down
Loading