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
56 changes: 55 additions & 1 deletion packages/turbo-gen/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { describe, it, expect, beforeAll, afterAll } from "@jest/globals";
import { execSync, spawn } from "node:child_process";
import { execFile, execSync, spawn } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";

const CLI = path.resolve(__dirname, "..", "dist", "cli.js");
const cliExists = fs.existsSync(CLI);
const execFileAsync = promisify(execFile);

function run(args: string[], cwd?: string): string {
const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
Expand Down Expand Up @@ -59,6 +61,20 @@ export default function generator(plop: any): void {
}
`;

const TS_CONFIG_RECORDING_FILENAME = `
import fs from "node:fs";

export default function generator(plop: any): void {
plop.setGenerator("race", {
prompts: [],
actions: [() => {
fs.writeFileSync(process.env.TURBO_GEN_OUTPUT!, __filename);
return "recorded bundle path";
}]
});
}
`;

// CJS config (works for .js without "type":"module" and .cjs)
const JS_CJS_CONFIG = (name: string) => `
module.exports = function generator(plop) {
Expand Down Expand Up @@ -200,6 +216,44 @@ describeIfBuilt("@turbo/gen CLI", () => {
});
});

// Regression test for https://github.com/vercel/turborepo/issues/13735
it("uses independent bundles for concurrent invocations", async () => {
const projectDir = path.join(tmpDir, "concurrent-invocations");
fs.mkdirSync(projectDir, { recursive: true });
createProject(projectDir, {
configFile: "config.ts",
configContent: TS_CONFIG_RECORDING_FILENAME
});

const outputs = ["first.txt", "second.txt"].map((file) =>
path.join(projectDir, file)
);
const args = [
CLI,
"raw",
"run",
"--json",
JSON.stringify({ root: projectDir, generator_name: "race" })
];

await Promise.all(
outputs.map((output) =>
execFileAsync(process.execPath, args, {
cwd: projectDir,
env: { ...process.env, TURBO_GEN_OUTPUT: output }
})
)
);

const bundlePaths = outputs.map((output) =>
fs.readFileSync(output, "utf8")
);
expect(bundlePaths[0]).not.toBe(bundlePaths[1]);
expect(bundlePaths.every((bundlePath) => !fs.existsSync(bundlePath))).toBe(
true
);
});

// ESM/CJS config loading matrix — covers every realistic combination of
// project "type" field, config file extension, and module syntax.
describe.each([
Expand Down
66 changes: 28 additions & 38 deletions packages/turbo-gen/src/utils/plop.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from "node:path";
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import fs from "fs-extra";
import type { Project } from "@turbo/workspaces";
import type { NodePlopAPI, PlopGenerator } from "node-plop";
Expand Down Expand Up @@ -39,14 +40,23 @@ async function createPlopFromConfig(
configPath: string,
destBasePath: string
): Promise<NodePlopAPI | undefined> {
const bundledConfigPath = await bundleConfigForLoading(configPath);
try {
return await nodePlop(await bundleConfigForLoading(configPath), {
return await nodePlop(bundledConfigPath, {
destBasePath,
force: false
});
} catch (e) {
logger.error(e);
return undefined;
} finally {
if (bundledConfigPath !== configPath) {
try {
fs.removeSync(bundledConfigPath);
} catch {
// Ignore cleanup failures.
}
}
}
}

Expand Down Expand Up @@ -101,25 +111,21 @@ export async function getCustomGenerators({

const gensByWorkspace: Record<string, Array<Generator>> = {};

try {
for (const conf of configs) {
const plop = await createPlopFromConfig(conf.config, conf.root);
if (!plop) {
continue;
}
for (const conf of configs) {
const plop = await createPlopFromConfig(conf.config, conf.root);
if (!plop) {
continue;
}

for (const g of plop.getGeneratorList()) {
const gen = plop.getGenerator(g.name) as Generator;
gen.workspace = conf.workspace;
gen.configPath = conf.config;
gen.destBasePath = conf.root;
for (const g of plop.getGeneratorList()) {
const gen = plop.getGenerator(g.name) as Generator;
gen.workspace = conf.workspace;
gen.configPath = conf.config;
gen.destBasePath = conf.root;

gensByWorkspace[conf.workspace] ??= [];
gensByWorkspace[conf.workspace].push(gen);
}
gensByWorkspace[conf.workspace] ??= [];
gensByWorkspace[conf.workspace].push(gen);
}
} finally {
cleanupBundledConfigs();
}

const result: Array<Generator | InstanceType<typeof Separator>> = [];
Expand Down Expand Up @@ -181,8 +187,6 @@ function injectTurborepoData({
// or ESM syntax. We use esbuild at runtime to bundle the user's config into a
// single CJS file before node-plop loads it. esbuild handles TS transpilation
// and ESM-to-CJS conversion transparently.
const bundled = new Set<string>();

// Modules provided by @turbo/gen that user configs may import without
// installing themselves (backward compat). When esbuild can't resolve these
// from the user's project, we resolve them from @turbo/gen's own node_modules.
Expand Down Expand Up @@ -214,7 +218,10 @@ function getOwnNodeModulesDirs(): Array<string> {
async function bundleConfigForLoading(configPath: string): Promise<string> {
const outName = path
.basename(configPath)
.replace(/\.(ts|js|cjs|mts|mjs)$/, ".turbo-gen-bundled.cjs");
.replace(
/\.(ts|js|cjs|mts|mjs)$/,
`.${randomUUID()}.turbo-gen-bundled.cjs`
);
const outDir = path.dirname(configPath);
const outPath = path.join(outDir, outName);

Expand Down Expand Up @@ -248,7 +255,6 @@ async function bundleConfigForLoading(configPath: string): Promise<string> {
return configPath;
}

bundled.add(outPath);
return outPath;
} catch {
return configPath;
Expand Down Expand Up @@ -313,17 +319,6 @@ function cliProvidedModulesPlugin(configPath: string) {
};
}

function cleanupBundledConfigs() {
for (const p of bundled) {
try {
fs.removeSync(p);
} catch {
// ignore cleanup failures
}
}
bundled.clear();
}

function getWorkspaceGeneratorConfigs({ project }: { project: Project }) {
const workspaceGeneratorConfigs: Array<{
config: string;
Expand Down Expand Up @@ -356,12 +351,7 @@ export async function runCustomGenerator({
const resolvedConfigPath = configPath ?? generator.configPath;
const destBasePath = configPath ?? generator.destBasePath;

let plop: NodePlopAPI | undefined;
try {
plop = await createPlopFromConfig(resolvedConfigPath, destBasePath);
} finally {
cleanupBundledConfigs();
}
const plop = await createPlopFromConfig(resolvedConfigPath, destBasePath);

if (!plop) {
throw new GeneratorError("Unable to load generators", {
Expand Down
Loading