[remix] Install @vercel/remix-run-dev at build-time instead of using symlink (#9784)

Instead of including the fork `@remix-run/dev` package as a regular dependency of `@vercel/remix-builder`, install it at build-time by modifying the project's `package.json` file. The reasons for this are:

* Avoids deprecation warnings from a few packages that currently exist on the `@remix-run/dev` package when installing Vercel CLI (those warnings already show up in the build logs anyways, so nothing new there).
* Allows us to install a version as close as possible to the version specified in the user's `package.json` (similar to how we do when auto-injecting the `@vercel/remix` package). This will be especially important once Remix v2 is released, which will have breaking changes compared to v1.

**Note:** `@vercel/remix-run-dev` is still a _dev_ dependency, so that we can use TypeScript types from it, as well as, at runtime, we use the version in the Builder's `package.json` to determine the maximum versions of `@vercel/remix-run-dev` and/or `@vercel/remix` which can safely be installed.

Fixes #10027.
Fixes #10222.
This commit is contained in:
Nathan Rajlich
2023-07-28 13:49:32 -07:00
committed by GitHub
parent d614709308
commit d1b0dbe3a7
12 changed files with 4474 additions and 3219 deletions

View File

@@ -0,0 +1,5 @@
---
'@vercel/remix-builder': minor
---
Install `@vercel/remix-run-dev` at build-time instead of using symlink

View File

@@ -20,7 +20,6 @@
"defaults" "defaults"
], ],
"dependencies": { "dependencies": {
"@remix-run/dev": "npm:@vercel/remix-run-dev@1.19.1",
"@vercel/build-utils": "6.8.2", "@vercel/build-utils": "6.8.2",
"@vercel/nft": "0.22.5", "@vercel/nft": "0.22.5",
"@vercel/static-config": "2.0.17", "@vercel/static-config": "2.0.17",
@@ -29,6 +28,7 @@
"ts-morph": "12.0.0" "ts-morph": "12.0.0"
}, },
"devDependencies": { "devDependencies": {
"@remix-run/dev": "npm:@vercel/remix-run-dev@1.19.1",
"@types/jest": "27.5.1", "@types/jest": "27.5.1",
"@types/node": "14.18.33", "@types/node": "14.18.33",
"@types/semver": "7.3.13" "@types/semver": "7.3.13"

View File

@@ -1,5 +1,5 @@
import { Project } from 'ts-morph'; import { Project } from 'ts-morph';
import { promises as fs } from 'fs'; import { readFileSync, promises as fs } from 'fs';
import { basename, dirname, extname, join, relative, sep } from 'path'; import { basename, dirname, extname, join, relative, sep } from 'path';
import { import {
debug, debug,
@@ -26,7 +26,6 @@ import type {
PackageJson, PackageJson,
BuildResultV2Typical, BuildResultV2Typical,
} from '@vercel/build-utils'; } from '@vercel/build-utils';
import type { AppConfig } from '@remix-run/dev/dist/config';
import type { ConfigRoute } from '@remix-run/dev/dist/config/routes'; import type { ConfigRoute } from '@remix-run/dev/dist/config/routes';
import type { BaseFunctionConfig } from '@vercel/static-config'; import type { BaseFunctionConfig } from '@vercel/static-config';
import { import {
@@ -41,16 +40,22 @@ import {
ResolvedEdgeRouteConfig, ResolvedEdgeRouteConfig,
findEntry, findEntry,
chdirAndReadConfig, chdirAndReadConfig,
addDependency, addDependencies,
resolveSemverMinMax,
ensureResolvable, ensureResolvable,
isESM,
} from './utils'; } from './utils';
import semver from 'semver';
const _require: typeof require = eval('require'); interface ServerBundle {
serverBuildPath: string;
routes: string[];
}
const REMIX_RUN_DEV_PATH = dirname( const remixBuilderPkg = JSON.parse(
_require.resolve('@remix-run/dev/package.json') readFileSync(join(__dirname, '../package.json'), 'utf8')
); );
const remixRunDevForkVersion =
remixBuilderPkg.devDependencies['@remix-run/dev'];
const DEFAULTS_PATH = join(__dirname, '../defaults'); const DEFAULTS_PATH = join(__dirname, '../defaults');
@@ -63,8 +68,17 @@ const nodeServerSrcPromise = fs.readFile(
'utf-8' 'utf-8'
); );
// This value is the minimum supported version for our fork of Remix // Minimum supported version of the `@vercel/remix` package
const minimumSupportRemixVersion = '1.10.0'; const VERCEL_REMIX_MIN_VERSION = '1.10.0';
// Minimum supported version of the `@vercel/remix-run-dev` forked compiler
const REMIX_RUN_DEV_MIN_VERSION = '1.15.0';
// Maximum version of `@vercel/remix-run-dev` fork
// (and also `@vercel/remix` since they get published at the same time)
const REMIX_RUN_DEV_MAX_VERSION = remixRunDevForkVersion.slice(
remixRunDevForkVersion.lastIndexOf('@') + 1
);
export const build: BuildV2 = async ({ export const build: BuildV2 = async ({
entrypoint, entrypoint,
@@ -133,18 +147,32 @@ export const build: BuildV2 = async ({
repoRootPath, repoRootPath,
'@remix-run/dev' '@remix-run/dev'
); );
const remixRunDevPkg = JSON.parse(
const remixVersion = JSON.parse( readFileSync(join(remixRunDevPath, 'package.json'), 'utf8')
await fs.readFile(join(remixRunDevPath, 'package.json'), 'utf8') );
).version; const remixVersion = remixRunDevPkg.version;
const remixConfig = await chdirAndReadConfig( const remixConfig = await chdirAndReadConfig(
remixRunDevPath,
entrypointFsDirname, entrypointFsDirname,
packageJsonPath packageJsonPath
); );
const { serverEntryPoint, appDirectory } = remixConfig; const { serverEntryPoint, appDirectory } = remixConfig;
const remixRoutes = Object.values(remixConfig.routes); const remixRoutes = Object.values(remixConfig.routes);
const depsToAdd: string[] = [];
if (remixRunDevPkg.name !== '@vercel/remix-run-dev') {
const remixDevForkVersion = resolveSemverMinMax(
REMIX_RUN_DEV_MIN_VERSION,
REMIX_RUN_DEV_MAX_VERSION,
remixVersion
);
depsToAdd.push(
`@remix-run/dev@npm:@vercel/remix-run-dev@${remixDevForkVersion}`
);
}
// `app/entry.server.tsx` and `app/entry.client.tsx` are optional in Remix, // `app/entry.server.tsx` and `app/entry.client.tsx` are optional in Remix,
// so if either of those files are missing then add our own versions. // so if either of those files are missing then add our own versions.
const userEntryServerFile = findEntry(appDirectory, 'entry.server'); const userEntryServerFile = findEntry(appDirectory, 'entry.server');
@@ -155,44 +183,27 @@ export const build: BuildV2 = async ({
); );
if (!pkg.dependencies['@vercel/remix']) { if (!pkg.dependencies['@vercel/remix']) {
// Dependency version resolution logic // Dependency version resolution logic
// 1. Users app is on 1.9.0 -> we install the 1.10.0 (minimum) version of our fork (`@vercel/remix`) // 1. Users app is on 1.9.0 -> we install the 1.10.0 (minimum) version of `@vercel/remix`.
// 2. Users app is on 1.11.0 (a version greater than 1.10.0 and less than the latest version of the fork) -> we install the (matching) 1.11.0 version of `@vercel/remix` // 2. Users app is on 1.11.0 (a version greater than 1.10.0 and less than the known max
// 3. Users app is on something greater than our latest version of the fork -> we install the latest version of our fork // published version) -> we install the (matching) 1.11.0 version of `@vercel/remix`.
// 3. Users app is on something greater than our latest version of the fork -> we install
// remixVersion is the version of the `@remix-run/dev` package in the *users' app* // the latest known published version of `@vercel/remix`.
const usersRemixVersion = semver.gt( const vercelRemixVersion = resolveSemverMinMax(
remixVersion, VERCEL_REMIX_MIN_VERSION,
minimumSupportRemixVersion REMIX_RUN_DEV_MAX_VERSION,
) remixVersion
? remixVersion
: minimumSupportRemixVersion;
// Prevent frozen lockfile rejections
const envForAddDep = { ...spawnOpts.env };
delete envForAddDep.CI;
delete envForAddDep.VERCEL;
delete envForAddDep.NOW_BUILDER;
await addDependency(
cliType,
[
`@vercel/remix@${
semver.gt(
usersRemixVersion,
require('@remix-run/dev/package.json').version
)
? 'latest'
: usersRemixVersion
}`,
],
{
...spawnOpts,
env: envForAddDep,
cwd: entrypointFsDirname,
}
); );
depsToAdd.push(`@vercel/remix@${vercelRemixVersion}`);
} }
} }
if (depsToAdd.length) {
await addDependencies(cliType, depsToAdd, {
...spawnOpts,
cwd: entrypointFsDirname,
});
}
const userEntryClientFile = findEntry( const userEntryClientFile = findEntry(
remixConfig.appDirectory, remixConfig.appDirectory,
'entry.client' 'entry.client'
@@ -210,12 +221,8 @@ export const build: BuildV2 = async ({
? `${remixConfigPath}.original${extname(remixConfigPath)}` ? `${remixConfigPath}.original${extname(remixConfigPath)}`
: undefined; : undefined;
const backupRemixRunDevPath = `${remixRunDevPath}.__vercel_backup`;
await fs.rename(remixRunDevPath, backupRemixRunDevPath);
await fs.symlink(REMIX_RUN_DEV_PATH, remixRunDevPath);
// These get populated inside the try/catch below // These get populated inside the try/catch below
let serverBundles: AppConfig['serverBundles']; let serverBundles: ServerBundle[];
const serverBundlesMap = new Map<string, ConfigRoute[]>(); const serverBundlesMap = new Map<string, ConfigRoute[]>();
const resolvedConfigsMap = new Map<ConfigRoute, ResolvedRouteConfig>(); const resolvedConfigsMap = new Map<ConfigRoute, ResolvedRouteConfig>();
@@ -273,16 +280,9 @@ export const build: BuildV2 = async ({
if (remixConfigPath && renamedRemixConfigPath) { if (remixConfigPath && renamedRemixConfigPath) {
await fs.rename(remixConfigPath, renamedRemixConfigPath); await fs.rename(remixConfigPath, renamedRemixConfigPath);
// Figure out if the `remix.config` file is using ESM syntax
let isESM = false;
try {
_require(renamedRemixConfigPath);
} catch (err: any) {
isESM = err.code === 'ERR_REQUIRE_ESM';
}
let patchedConfig: string; let patchedConfig: string;
if (isESM) { // Figure out if the `remix.config` file is using ESM syntax
if (isESM(renamedRemixConfigPath)) {
patchedConfig = `import config from './${basename( patchedConfig = `import config from './${basename(
renamedRemixConfigPath renamedRemixConfigPath
)}'; )}';
@@ -340,10 +340,6 @@ module.exports = config;`;
if (remixConfigWrapped && remixConfigPath && renamedRemixConfigPath) { if (remixConfigWrapped && remixConfigPath && renamedRemixConfigPath) {
await fs.rename(renamedRemixConfigPath, remixConfigPath); await fs.rename(renamedRemixConfigPath, remixConfigPath);
} }
// Remove `@remix-run/dev` symlink
await fs.unlink(remixRunDevPath);
await fs.rename(backupRemixRunDevPath, remixRunDevPath);
} }
// This needs to happen before we run NFT to create the Node/Edge functions // This needs to happen before we run NFT to create the Node/Edge functions

View File

@@ -1,6 +1,6 @@
import { glob } from '@vercel/build-utils'; import { glob } from '@vercel/build-utils';
import { dirname, join, relative } from 'path'; import { dirname, join, relative } from 'path';
import { chdirAndReadConfig } from './utils'; import { _require, chdirAndReadConfig } from './utils';
import type { PrepareCache } from '@vercel/build-utils'; import type { PrepareCache } from '@vercel/build-utils';
export const prepareCache: PrepareCache = async ({ export const prepareCache: PrepareCache = async ({
@@ -12,7 +12,13 @@ export const prepareCache: PrepareCache = async ({
const mountpoint = dirname(entrypoint); const mountpoint = dirname(entrypoint);
const entrypointFsDirname = join(workPath, mountpoint); const entrypointFsDirname = join(workPath, mountpoint);
const packageJsonPath = join(entrypointFsDirname, 'package.json'); const packageJsonPath = join(entrypointFsDirname, 'package.json');
const remixRunDevPath = dirname(
_require.resolve('@remix-run/dev/package.json', {
paths: [entrypointFsDirname],
})
);
const remixConfig = await chdirAndReadConfig( const remixConfig = await chdirAndReadConfig(
remixRunDevPath,
entrypointFsDirname, entrypointFsDirname,
packageJsonPath packageJsonPath
); );

View File

@@ -1,8 +1,8 @@
import semver from 'semver';
import { existsSync, promises as fs } from 'fs'; import { existsSync, promises as fs } from 'fs';
import { basename, dirname, join, relative, resolve, sep } from 'path'; import { basename, dirname, join, relative, resolve, sep } from 'path';
import { pathToRegexp, Key } from 'path-to-regexp'; import { pathToRegexp, Key } from 'path-to-regexp';
import { debug, spawnAsync } from '@vercel/build-utils'; import { debug, spawnAsync } from '@vercel/build-utils';
import { readConfig } from '@remix-run/dev/dist/config';
import { walkParentDirs } from '@vercel/build-utils'; import { walkParentDirs } from '@vercel/build-utils';
import type { import type {
ConfigRoute, ConfigRoute,
@@ -15,12 +15,15 @@ import type {
SpawnOptionsExtended, SpawnOptionsExtended,
} from '@vercel/build-utils/dist/fs/run-user-scripts'; } from '@vercel/build-utils/dist/fs/run-user-scripts';
export const _require: typeof require = eval('require');
export interface ResolvedNodeRouteConfig { export interface ResolvedNodeRouteConfig {
runtime: 'nodejs'; runtime: 'nodejs';
regions?: string[]; regions?: string[];
maxDuration?: number; maxDuration?: number;
memory?: number; memory?: number;
} }
export interface ResolvedEdgeRouteConfig { export interface ResolvedEdgeRouteConfig {
runtime: 'edge'; runtime: 'edge';
regions?: BaseFunctionConfig['regions']; regions?: BaseFunctionConfig['regions'];
@@ -43,8 +46,6 @@ export interface ResolvedRoutePaths {
rePath: string; rePath: string;
} }
const _require: typeof require = eval('require');
const SPLAT_PATH = '/:params*'; const SPLAT_PATH = '/:params*';
const entryExts = ['.js', '.jsx', '.ts', '.tsx']; const entryExts = ['.js', '.jsx', '.ts', '.tsx'];
@@ -212,7 +213,14 @@ export function syncEnv(source: NodeJS.ProcessEnv, dest: NodeJS.ProcessEnv) {
return () => syncEnv(originalDest, dest); return () => syncEnv(originalDest, dest);
} }
export async function chdirAndReadConfig(dir: string, packageJsonPath: string) { export async function chdirAndReadConfig(
remixRunDevPath: string,
dir: string,
packageJsonPath: string
) {
const { readConfig }: typeof import('@remix-run/dev/dist/config') =
await import(join(remixRunDevPath, 'dist/config.js'));
const originalCwd = process.cwd(); const originalCwd = process.cwd();
// As of Remix v1.14.0, reading the config may trigger adding // As of Remix v1.14.0, reading the config may trigger adding
@@ -249,18 +257,22 @@ export async function chdirAndReadConfig(dir: string, packageJsonPath: string) {
return remixConfig; return remixConfig;
} }
export interface AddDependencyOptions extends SpawnOptionsExtended { export interface AddDependenciesOptions extends SpawnOptionsExtended {
saveDev?: boolean; saveDev?: boolean;
} }
/** /**
* Runs `npm i ${name}` / `pnpm i ${name}` / `yarn add ${name}`. * Runs `npm i ${name}` / `pnpm i ${name}` / `yarn add ${name}`.
*/ */
export function addDependency( export function addDependencies(
cliType: CliType, cliType: CliType,
names: string[], names: string[],
opts: AddDependencyOptions = {} opts: AddDependenciesOptions = {}
) { ) {
debug('Installing additional dependencies:');
for (const name of names) {
debug(` - ${name}`);
}
const args: string[] = []; const args: string[] = [];
if (cliType === 'npm' || cliType === 'pnpm') { if (cliType === 'npm' || cliType === 'pnpm') {
args.push('install'); args.push('install');
@@ -277,6 +289,15 @@ export function addDependency(
return spawnAsync(cliType, args.concat(names), opts); return spawnAsync(cliType, args.concat(names), opts);
} }
export function resolveSemverMinMax(
min: string,
max: string,
version: string
): string {
const floored = semver.intersects(version, `>= ${min}`) ? version : min;
return semver.intersects(floored, `<= ${max}`) ? floored : max;
}
export async function ensureResolvable( export async function ensureResolvable(
start: string, start: string,
base: string, base: string,
@@ -369,3 +390,14 @@ async function ensureSymlink(
await fs.symlink(relativeTarget, symlinkPath); await fs.symlink(relativeTarget, symlinkPath);
debug(`Created symlink for "${pkgName}"`); debug(`Created symlink for "${pkgName}"`);
} }
export function isESM(path: string): boolean {
// Figure out if the `remix.config` file is using ESM syntax
let isESM = false;
try {
_require(path);
} catch (err: any) {
isESM = err.code === 'ERR_REQUIRE_ESM';
}
return isESM;
}

View File

@@ -10,14 +10,14 @@
"start": "remix-serve build" "start": "remix-serve build"
}, },
"dependencies": { "dependencies": {
"@remix-run/node": "^1.7.4", "@remix-run/node": "1.15.0",
"@remix-run/react": "^1.7.4", "@remix-run/react": "1.15.0",
"@remix-run/serve": "^1.7.4", "@remix-run/serve": "1.15.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0" "react-dom": "18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@remix-run/dev": "^1.7.4", "@remix-run/dev": "1.15.0",
"@types/react": "^17.0.45", "@types/react": "^17.0.45",
"@types/react-dom": "^17.0.17", "@types/react-dom": "^17.0.17",
"typescript": "^4.6.4" "typescript": "^4.6.4"

File diff suppressed because it is too large Load Diff

View File

@@ -10,13 +10,13 @@
"start": "remix-serve build" "start": "remix-serve build"
}, },
"dependencies": { "dependencies": {
"@remix-run/react": "1.5.0", "@remix-run/react": "1.10.0",
"@remix-run/serve": "1.5.0", "@remix-run/serve": "1.10.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0" "react-dom": "18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@remix-run/dev": "1.5.0", "@remix-run/dev": "1.10.0",
"@types/react": "^17.0.45", "@types/react": "^17.0.45",
"@types/react-dom": "^17.0.17", "@types/react-dom": "^17.0.17",
"typescript": "^4.6.4" "typescript": "^4.6.4"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
import { resolveSemverMinMax } from '../src/utils';
describe('resolveSemverMinMax()', () => {
it.each([
{ min: '1.0.0', max: '1.15.0', version: '0.9.0', expected: '1.0.0' },
{ min: '1.0.0', max: '1.15.0', version: '1.0.0', expected: '1.0.0' },
{ min: '1.0.0', max: '1.15.0', version: '1.1.0', expected: '1.1.0' },
{ min: '1.0.0', max: '1.15.0', version: '1.10.0', expected: '1.10.0' },
{ min: '1.0.0', max: '1.15.0', version: '1.15.0', expected: '1.15.0' },
{ min: '1.0.0', max: '1.15.0', version: '1.16.0', expected: '1.15.0' },
{ min: '1.0.0', max: '1.15.0', version: '^1.12.0', expected: '^1.12.0' },
{ min: '1.0.0', max: '1.15.0', version: '0.x.x', expected: '1.0.0' },
])(
'Should return "$expected" for version "$version" (min=$min, max=$max)',
({ min, max, version, expected }) => {
const actual = resolveSemverMinMax(min, max, version);
expect(actual).toEqual(expected);
}
);
});

996
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,14 +11,13 @@ module.exports = async ({ github, context }, newVersion) => {
const packagePath = path.join(repoRootPath, 'packages', 'remix'); const packagePath = path.join(repoRootPath, 'packages', 'remix');
const oldVersion = JSON.parse( const oldVersion = JSON.parse(
fs.readFileSync(path.join(packagePath, 'package.json'), 'utf-8') fs.readFileSync(path.join(packagePath, 'package.json'), 'utf-8')
).dependencies['@remix-run/dev']; ).devDependencies['@remix-run/dev'];
if (newVersion === '') { if (newVersion === '') {
newVersion = execSync('npm view @vercel/remix-run-dev dist-tags.latest', { newVersion = execSync('npm view @vercel/remix-run-dev dist-tags.latest', {
encoding: 'utf-8', encoding: 'utf-8',
}); });
} }
newVersion = newVersion.trim(); newVersion = newVersion.trim();
const branch = `vercel-remix-run-dev-${newVersion.replaceAll('.', '-')}`;
if (oldVersion === newVersion) { if (oldVersion === newVersion) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@@ -28,6 +27,7 @@ module.exports = async ({ github, context }, newVersion) => {
return; return;
} }
const branch = `vercel-remix-run-dev-${newVersion.replaceAll('.', '-')}`;
if ( if (
execSync(`git ls-remote --heads origin ${branch}`, { encoding: 'utf-8' }) execSync(`git ls-remote --heads origin ${branch}`, { encoding: 'utf-8' })
.toString() .toString()
@@ -39,7 +39,7 @@ module.exports = async ({ github, context }, newVersion) => {
} }
execSync( execSync(
`pnpm install @remix-run/dev@npm:@vercel/remix-run-dev@${newVersion} --save-exact --lockfile-only`, `pnpm install @remix-run/dev@npm:@vercel/remix-run-dev@${newVersion} --save-exact --save-dev --lockfile-only`,
{ cwd: packagePath } { cwd: packagePath }
); );