Colum Ferry 229cf28198 feat(remix): add continuous task support (#29905)
## Current Behavior
When `serve, dev, start` targets are inferred by the `@nx/remix` plugin,
they are not inferring `continuous`.


## Expected Behavior
When `NX_CONTINUOUS_TASKS_PREVIEW` env var exists, infer the
`continuous` property.
2025-04-15 09:23:45 -04:00

489 lines
13 KiB
TypeScript

import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import { hashObject } from 'nx/src/hasher/file-hasher';
import {
type CreateDependencies,
type CreateNodes,
type CreateNodesContext,
createNodesFromFiles,
CreateNodesV2,
detectPackageManager,
getPackageManagerCommand,
joinPathFragments,
logger,
ProjectConfiguration,
readJsonFile,
type TargetConfiguration,
writeJsonFile,
} from '@nx/devkit';
import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes';
import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs';
import { loadConfigFile } from '@nx/devkit/src/utils/config-utils';
import { getLockFileName } from '@nx/js';
import { type AppConfig } from '@remix-run/dev';
import { dirname, join } from 'path';
import { existsSync, readdirSync, readFileSync } from 'fs';
import { loadViteDynamicImport } from '../utils/executor-utils';
import { addBuildAndWatchDepsTargets } from '@nx/js/src/plugins/typescript/util';
import { isUsingTsSolutionSetup as _isUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-solution-setup';
export interface RemixPluginOptions {
buildTargetName?: string;
devTargetName?: string;
startTargetName?: string;
typecheckTargetName?: string;
buildDepsTargetName?: string;
watchDepsTargetName?: string;
/**
* @deprecated Use serveStaticTargetName instead. This option will be removed in Nx 21.
*/
staticServeTargetName?: string;
serveStaticTargetName?: string;
}
const pmc = getPackageManagerCommand();
type RemixTargets = Pick<ProjectConfiguration, 'targets' | 'metadata'>;
function readTargetsCache(
cachePath: string
): Record<string, Record<string, TargetConfiguration>> {
return existsSync(cachePath) ? readJsonFile(cachePath) : {};
}
function writeTargetsToCache(
cachePath: string,
results: Record<string, RemixTargets>
) {
writeJsonFile(cachePath, results);
}
/**
* @deprecated The 'createDependencies' function is now a no-op. This functionality is included in 'createNodesV2'.
*/
export const createDependencies: CreateDependencies = () => {
return [];
};
const remixConfigGlob = '**/{remix,vite}.config.{js,cjs,mjs,ts,cts,mts}';
export const createNodesV2: CreateNodesV2<RemixPluginOptions> = [
remixConfigGlob,
async (configFilePaths, options, context) => {
const optionsHash = hashObject(options);
const cachePath = join(workspaceDataDirectory, `remix-${optionsHash}.hash`);
const targetsCache = readTargetsCache(cachePath);
try {
return await createNodesFromFiles(
(configFile, options, context) =>
createNodesInternal(
configFile,
options,
context,
targetsCache,
_isUsingTsSolutionSetup()
),
configFilePaths,
options,
context
);
} finally {
writeTargetsToCache(cachePath, targetsCache);
}
},
];
export const createNodes: CreateNodes<RemixPluginOptions> = [
remixConfigGlob,
async (configFilePath, options, context) => {
logger.warn(
'`createNodes` is deprecated. Update your plugin to utilize createNodesV2 instead. In Nx 20, this will change to the createNodesV2 API.'
);
return createNodesInternal(
configFilePath,
options,
context,
{},
_isUsingTsSolutionSetup()
);
},
];
async function createNodesInternal(
configFilePath: string,
options: RemixPluginOptions,
context: CreateNodesContext,
targetsCache: Record<string, RemixTargets>,
isUsingTsSolutionSetup: boolean
) {
const projectRoot = dirname(configFilePath);
const fullyQualifiedProjectRoot = join(context.workspaceRoot, projectRoot);
// Do not create a project if package.json and project.json isn't there
const siblingFiles = readdirSync(fullyQualifiedProjectRoot);
if (
!siblingFiles.includes('package.json') &&
!siblingFiles.includes('project.json')
) {
return {};
}
options = normalizeOptions(options);
const remixCompiler = determineIsRemixVite(
configFilePath,
context.workspaceRoot
);
if (remixCompiler === RemixCompiler.IsNotRemix) {
return {};
}
const hash =
(await calculateHashForCreateNodes(
projectRoot,
{ ...options, isUsingTsSolutionSetup },
context,
[getLockFileName(detectPackageManager(context.workspaceRoot))]
)) + configFilePath;
targetsCache[hash] ??= await buildRemixTargets(
configFilePath,
projectRoot,
options,
context,
siblingFiles,
remixCompiler,
isUsingTsSolutionSetup
);
const { targets, metadata } = targetsCache[hash];
const project: ProjectConfiguration = {
root: projectRoot,
targets,
metadata,
};
return {
projects: {
[projectRoot]: project,
},
};
}
async function buildRemixTargets(
configFilePath: string,
projectRoot: string,
options: RemixPluginOptions,
context: CreateNodesContext,
siblingFiles: string[],
remixCompiler: RemixCompiler,
isUsingTsSolutionSetup: boolean
) {
const namedInputs = getNamedInputs(projectRoot, context);
const { buildDirectory, assetsBuildDirectory, serverBuildPath } =
await getBuildPaths(
configFilePath,
projectRoot,
context.workspaceRoot,
remixCompiler
);
const targets: Record<string, TargetConfiguration> = {};
targets[options.buildTargetName] = buildTarget(
options.buildTargetName,
projectRoot,
buildDirectory,
assetsBuildDirectory,
namedInputs,
remixCompiler,
isUsingTsSolutionSetup
);
targets[options.devTargetName] = devTarget(
serverBuildPath,
projectRoot,
remixCompiler,
isUsingTsSolutionSetup
);
targets[options.startTargetName] = startTarget(
projectRoot,
serverBuildPath,
options.buildTargetName,
remixCompiler,
isUsingTsSolutionSetup
);
// TODO(colum): Remove for Nx 21
targets[options.staticServeTargetName] = startTarget(
projectRoot,
serverBuildPath,
options.buildTargetName,
remixCompiler,
isUsingTsSolutionSetup
);
targets[options.serveStaticTargetName] = startTarget(
projectRoot,
serverBuildPath,
options.buildTargetName,
remixCompiler,
isUsingTsSolutionSetup
);
targets[options.typecheckTargetName] = typecheckTarget(
options.typecheckTargetName,
projectRoot,
namedInputs,
siblingFiles,
isUsingTsSolutionSetup
);
addBuildAndWatchDepsTargets(
context.workspaceRoot,
projectRoot,
targets,
options,
pmc
);
return { targets, metadata: {} };
}
function buildTarget(
buildTargetName: string,
projectRoot: string,
buildDirectory: string,
assetsBuildDirectory: string,
namedInputs: { [inputName: string]: any[] },
remixCompiler: RemixCompiler,
isUsingTsSolutionSetup: boolean
): TargetConfiguration {
const serverBuildOutputPath =
projectRoot === '.'
? joinPathFragments(`{workspaceRoot}`, buildDirectory)
: joinPathFragments(`{workspaceRoot}`, projectRoot, buildDirectory);
const assetsBuildOutputPath =
projectRoot === '.'
? joinPathFragments(`{workspaceRoot}`, assetsBuildDirectory)
: joinPathFragments(`{workspaceRoot}`, projectRoot, assetsBuildDirectory);
const outputs =
remixCompiler === RemixCompiler.IsVte
? [
projectRoot === '.'
? joinPathFragments(`{workspaceRoot}`, buildDirectory)
: joinPathFragments(`{workspaceRoot}`, projectRoot, buildDirectory),
]
: [serverBuildOutputPath, assetsBuildOutputPath];
const buildTarget: TargetConfiguration = {
cache: true,
dependsOn: [`^${buildTargetName}`],
inputs: [
...('production' in namedInputs
? ['production', '^production']
: ['default', '^default']),
{ externalDependencies: ['@remix-run/dev'] },
],
outputs,
command:
remixCompiler === RemixCompiler.IsVte
? 'remix vite:build'
: 'remix build',
options: { cwd: projectRoot },
};
if (isUsingTsSolutionSetup) {
buildTarget.syncGenerators = ['@nx/js:typescript-sync'];
}
return buildTarget;
}
function devTarget(
serverBuildPath: string,
projectRoot: string,
remixCompiler: RemixCompiler,
isUsingTsSolutionSetup: boolean
): TargetConfiguration {
const devTarget: TargetConfiguration = {
continuous: true,
command:
remixCompiler === RemixCompiler.IsVte
? 'remix vite:dev'
: 'remix dev --manual',
options: { cwd: projectRoot },
};
if (isUsingTsSolutionSetup) {
devTarget.syncGenerators = ['@nx/js:typescript-sync'];
}
return devTarget;
}
function startTarget(
projectRoot: string,
serverBuildPath: string,
buildTargetName: string,
remixCompiler: RemixCompiler,
isUsingTsSolutionSetup: boolean
): TargetConfiguration {
let serverPath = serverBuildPath;
if (remixCompiler === RemixCompiler.IsVte) {
if (serverBuildPath === 'build') {
serverPath = `${serverBuildPath}/server/index.js`;
}
}
const startTarget: TargetConfiguration = {
dependsOn: [buildTargetName],
continuous: true,
command: `remix-serve ${serverPath}`,
options: {
cwd: projectRoot,
},
};
if (isUsingTsSolutionSetup) {
startTarget.syncGenerators = ['@nx/js:typescript-sync'];
}
return startTarget;
}
function typecheckTarget(
typecheckTargetName: string,
projectRoot: string,
namedInputs: { [inputName: string]: any[] },
siblingFiles: string[],
isUsingTsSolutionSetup: boolean
): TargetConfiguration {
const hasTsConfigAppJson = siblingFiles.includes('tsconfig.app.json');
const typecheckTarget: TargetConfiguration = {
cache: true,
inputs: [
...('production' in namedInputs
? ['production', '^production']
: ['default', '^default']),
{ externalDependencies: ['typescript'] },
],
command: isUsingTsSolutionSetup
? `tsc --build --emitDeclarationOnly`
: `tsc${hasTsConfigAppJson ? ` -p tsconfig.app.json` : ``} --noEmit`,
options: {
cwd: projectRoot,
},
metadata: {
description: `Runs type-checking for the project.`,
technologies: ['typescript'],
help: {
command: isUsingTsSolutionSetup
? `${pmc.exec} tsc --build --help`
: `${pmc.exec} tsc${
hasTsConfigAppJson ? ` -p tsconfig.app.json` : ``
} --help`,
example: isUsingTsSolutionSetup
? { args: ['--force'] }
: { options: { noEmit: true } },
},
},
};
if (isUsingTsSolutionSetup) {
typecheckTarget.dependsOn = [`^${typecheckTargetName}`];
typecheckTarget.syncGenerators = ['@nx/js:typescript-sync'];
}
return typecheckTarget;
}
async function getBuildPaths(
configFilePath: string,
projectRoot: string,
workspaceRoot: string,
remixCompiler: RemixCompiler
): Promise<{
buildDirectory: string;
assetsBuildDirectory?: string;
serverBuildPath?: string;
}> {
const configPath = join(workspaceRoot, configFilePath);
if (remixCompiler === RemixCompiler.IsClassic) {
let appConfig = await loadConfigFile<AppConfig>(configPath);
return {
buildDirectory: 'build',
serverBuildPath: appConfig.serverBuildPath ?? 'build/index.js',
assetsBuildDirectory: appConfig.assetsBuildDirectory ?? 'public/build',
};
} else {
// Workaround for the `build$3 is not a function` error that we sometimes see in agents.
// This should be removed later once we address the issue properly
try {
const importEsbuild = () => new Function('return import("esbuild")')();
await importEsbuild();
} catch {
// do nothing
}
const { resolveConfig } = await loadViteDynamicImport();
const viteBuildConfig = (await resolveConfig(
{
configFile: configPath,
mode: 'development',
},
'build'
)) as any;
return {
buildDirectory: viteBuildConfig.build?.outDir ?? 'build',
serverBuildPath: viteBuildConfig.build?.outDir
? join(
dirname(viteBuildConfig.build?.outDir),
`server/${viteBuildConfig.__remixPluginContext?.remixConfig.serverBuildFile}`
)
: 'build',
assetsBuildDirectory: 'build/client',
};
}
}
function normalizeOptions(options: RemixPluginOptions) {
options ??= {};
options.buildTargetName ??= 'build';
options.devTargetName ??= 'dev';
options.startTargetName ??= 'start';
options.typecheckTargetName ??= 'typecheck';
// TODO(colum): remove for Nx 21
options.staticServeTargetName ??= 'static-serve';
options.serveStaticTargetName ??= 'serve-static';
return options;
}
function determineIsRemixVite(configFilePath: string, workspaceRoot: string) {
if (configFilePath.includes('remix.config')) {
return RemixCompiler.IsClassic;
}
const VITE_PLUGIN_REGEX = /vitePlugin\(\s*(.|\n)*?\s*\)/;
const REMIX_PLUGIN_REGEX = /remix\(\s*(.|\n)*?\s*\)/;
const fileContents = readFileSync(
join(workspaceRoot, configFilePath),
'utf8'
);
if (
fileContents.includes('@remix-run/dev') &&
(VITE_PLUGIN_REGEX.test(fileContents) ||
REMIX_PLUGIN_REGEX.test(fileContents))
) {
return RemixCompiler.IsVte;
} else {
return RemixCompiler.IsNotRemix;
}
}
enum RemixCompiler {
IsClassic = 1,
IsVte = 2,
IsNotRemix = 3,
}