[remix] Support optional entry.{server,client}.tsx file (#9620)

Remix v1.14.0 added support for having no `app/entry.server.tsx`/`app/entry.client.tsx` files in a project (there are default versions bundled into `@remix-run/dev`). Projects configured like this are currently failing because we symlink our forked version of the `remix` CLI into the project, so it cannot resolve the necessary modules at build time.

To solve this, instead of relying on the default versions of these files in `@remix-run/dev` package, we'll include our own versions in `@vercel/remix`, and physically copy them into the project dir. This way, the modules used will be properly resolved relative to the project's own `node_modules` dir.

Our default version of `app/entry.server.tsx` is also slightly different then upstream one, because it uses `@vercel/remix-entry-server` to enable isomorphic React streaming on both Node + Edge runtimes. Because of this, if that dependency is not present, then we'll automatically install the dependency at build-time.
This commit is contained in:
Nathan Rajlich
2023-03-13 13:47:56 -07:00
committed by GitHub
parent bada86b8d6
commit 13d54b2095
36 changed files with 15635 additions and 167 deletions

View File

@@ -13,7 +13,6 @@ import {
glob,
EdgeFunction,
NodejsLambda,
readConfigFile,
runNpmInstall,
runPackageJsonScript,
scanParentDirs,
@@ -21,7 +20,6 @@ import {
} from '@vercel/build-utils';
import { getConfig } from '@vercel/static-config';
import { nodeFileTrace } from '@vercel/nft';
import { readConfig } from '@remix-run/dev/dist/config';
import type {
BuildV2,
Files,
@@ -29,9 +27,9 @@ import type {
PackageJson,
BuildResultV2Typical,
} from '@vercel/build-utils';
import type { BaseFunctionConfig } from '@vercel/static-config';
import type { AppConfig, RemixConfig } from '@remix-run/dev/dist/config';
import type { AppConfig } from '@remix-run/dev/dist/config';
import type { ConfigRoute } from '@remix-run/dev/dist/config/routes';
import type { BaseFunctionConfig } from '@vercel/static-config';
import {
calculateRouteConfigHash,
findConfig,
@@ -42,6 +40,9 @@ import {
ResolvedRouteConfig,
ResolvedNodeRouteConfig,
ResolvedEdgeRouteConfig,
findEntry,
chdirAndReadConfig,
addDependency,
} from './utils';
const _require: typeof require = eval('require');
@@ -50,12 +51,14 @@ const REMIX_RUN_DEV_PATH = dirname(
_require.resolve('@remix-run/dev/package.json')
);
const DEFAULTS_PATH = join(__dirname, '../defaults');
const edgeServerSrcPromise = fs.readFile(
join(__dirname, '../server-edge.mjs'),
join(DEFAULTS_PATH, 'server-edge.mjs'),
'utf-8'
);
const nodeServerSrcPromise = fs.readFile(
join(__dirname, '../server-node.mjs'),
join(DEFAULTS_PATH, 'server-node.mjs'),
'utf-8'
);
@@ -82,19 +85,27 @@ export const build: BuildV2 = async ({
meta
);
const { cliType, packageJsonPath, lockfileVersion } = await scanParentDirs(
entrypointFsDirname
);
if (!packageJsonPath) {
throw new Error('Failed to locate `package.json` file in your project');
}
const pkgRaw = await fs.readFile(packageJsonPath, 'utf8');
const pkg = JSON.parse(pkgRaw);
const spawnOpts = getSpawnOptions(meta, nodeVersion);
if (!spawnOpts.env) {
spawnOpts.env = {};
}
const { cliType, lockfileVersion } = await scanParentDirs(
entrypointFsDirname
);
spawnOpts.env = getEnvForPackageManager({
cliType,
lockfileVersion,
nodeVersion,
env: spawnOpts.env || {},
env: spawnOpts.env,
});
if (typeof installCommand === 'string') {
@@ -111,9 +122,8 @@ export const build: BuildV2 = async ({
await runNpmInstall(entrypointFsDirname, [], spawnOpts, meta, nodeVersion);
}
// Make our version of `remix` CLI available to the project's build
// command by creating a symlink to the copy in our node modules,
// so that `serverBundles` works: https://github.com/remix-run/remix/pull/5479
// Determine the version of Remix based on the `@remix-run/dev`
// package version.
const remixRunDevPath = await ensureResolvable(
entrypointFsDirname,
repoRootPath,
@@ -124,6 +134,46 @@ export const build: BuildV2 = async ({
await fs.readFile(join(remixRunDevPath, 'package.json'), 'utf8')
).version;
const remixConfig = await chdirAndReadConfig(
entrypointFsDirname,
packageJsonPath
);
const { serverEntryPoint, appDirectory } = remixConfig;
const remixRoutes = Object.values(remixConfig.routes);
// `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.
const userEntryServerFile = findEntry(appDirectory, 'entry.server');
if (!userEntryServerFile) {
await fs.copyFile(
join(DEFAULTS_PATH, 'entry.server.jsx'),
join(appDirectory, 'entry.server.jsx')
);
if (!pkg.dependencies['@vercel/remix-entry-server']) {
// Prevent frozen lockfile rejections
const envForAddDep = { ...spawnOpts.env };
delete envForAddDep.CI;
delete envForAddDep.VERCEL;
delete envForAddDep.NOW_BUILDER;
await addDependency(cliType, ['@vercel/remix-entry-server'], {
...spawnOpts,
env: envForAddDep,
cwd: entrypointFsDirname,
});
}
}
const userEntryClientFile = findEntry(
remixConfig.appDirectory,
'entry.client'
);
if (!userEntryClientFile) {
await fs.copyFile(
join(DEFAULTS_PATH, 'entry.client.react.jsx'),
join(appDirectory, 'entry.client.jsx')
);
}
let remixConfigWrapped = false;
const remixConfigPath = findConfig(entrypointFsDirname, 'remix.config');
const renamedRemixConfigPath = remixConfigPath
@@ -136,18 +186,10 @@ export const build: BuildV2 = async ({
// These get populated inside the try/catch below
let serverBundles: AppConfig['serverBundles'];
let remixConfig: RemixConfig;
let remixRoutes: ConfigRoute[];
const serverBundlesMap = new Map<string, ConfigRoute[]>();
const resolvedConfigsMap = new Map<ConfigRoute, ResolvedRouteConfig>();
try {
// Make `remix build` output production mode
spawnOpts.env.NODE_ENV = 'production';
remixConfig = await chdirAndReadConfig(entrypointFsDirname);
remixRoutes = Object.values(remixConfig.routes);
// Read the `export const config` (if any) for each route
const project = new Project();
const staticConfigsMap = new Map<ConfigRoute, BaseFunctionConfig | null>();
@@ -235,6 +277,9 @@ module.exports = config;`;
remixConfigWrapped = true;
}
// Make `remix build` output production mode
spawnOpts.env.NODE_ENV = 'production';
// Run "Build Command"
if (buildCommand) {
debug(`Executing build command "${buildCommand}"`);
@@ -243,9 +288,6 @@ module.exports = config;`;
cwd: entrypointFsDirname,
});
} else {
const pkg = await readConfigFile<PackageJson>(
join(entrypointFsDirname, 'package.json')
);
if (hasScript('vercel-build', pkg)) {
debug(`Executing "yarn vercel-build"`);
await runPackageJsonScript(
@@ -297,7 +339,7 @@ module.exports = config;`;
entrypointFsDirname,
repoRootPath,
join(entrypointFsDirname, bundle.serverBuildPath),
remixConfig.serverEntryPoint,
serverEntryPoint,
remixVersion,
config
);
@@ -308,7 +350,7 @@ module.exports = config;`;
entrypointFsDirname,
repoRootPath,
join(entrypointFsDirname, bundle.serverBuildPath),
remixConfig.serverEntryPoint,
serverEntryPoint,
remixVersion,
config
);
@@ -527,8 +569,8 @@ async function createRenderEdgeFunction(
// Copy in the edge entrypoint so that NFT can properly resolve it
const vercelEdgeEntrypointPath = join(
__dirname,
'../vercel-edge-entrypoint.js'
DEFAULTS_PATH,
'vercel-edge-entrypoint.js'
);
const vercelEdgeEntrypointDest = join(
dirname(fsPath),
@@ -682,18 +724,6 @@ async function ensureSymlink(
);
}
async function chdirAndReadConfig(dir: string) {
const originalCwd = process.cwd();
let remixConfig: RemixConfig;
try {
process.chdir(dir);
remixConfig = await readConfig(dir);
} finally {
process.chdir(originalCwd);
}
return remixConfig;
}
async function writeEntrypointFile(
path: string,
data: string,

View File

@@ -1,6 +1,6 @@
import { glob } from '@vercel/build-utils';
import { dirname, join, relative } from 'path';
import { readConfig } from '@remix-run/dev/dist/config';
import { chdirAndReadConfig } from './utils';
import type { PrepareCache } from '@vercel/build-utils';
export const prepareCache: PrepareCache = async ({
@@ -11,7 +11,11 @@ export const prepareCache: PrepareCache = async ({
const root = repoRootPath || workPath;
const mountpoint = dirname(entrypoint);
const entrypointFsDirname = join(workPath, mountpoint);
const remixConfig = await readConfig(entrypointFsDirname);
const packageJsonPath = join(entrypointFsDirname, 'package.json');
const remixConfig = await chdirAndReadConfig(
entrypointFsDirname,
packageJsonPath
);
const [nodeModulesFiles, cacheDirFiles] = await Promise.all([
// Cache `node_modules`
glob('**/node_modules/**', root),

View File

@@ -1,11 +1,18 @@
import { join } from 'path';
import { existsSync } from 'fs';
import { existsSync, promises as fs } from 'fs';
import { join, relative, resolve } from 'path';
import { pathToRegexp, Key } from 'path-to-regexp';
import { spawnAsync } from '@vercel/build-utils';
import { readConfig } from '@remix-run/dev/dist/config';
import type {
ConfigRoute,
RouteManifest,
} from '@remix-run/dev/dist/config/routes';
import type { RemixConfig } from '@remix-run/dev/dist/config';
import type { BaseFunctionConfig } from '@vercel/static-config';
import type {
CliType,
SpawnOptionsExtended,
} from '@vercel/build-utils/dist/fs/run-user-scripts';
export interface ResolvedNodeRouteConfig {
runtime: 'nodejs';
@@ -37,6 +44,17 @@ export interface ResolvedRoutePaths {
const SPLAT_PATH = '/:params+';
const entryExts = ['.js', '.jsx', '.ts', '.tsx'];
export function findEntry(dir: string, basename: string): string | undefined {
for (const ext of entryExts) {
const file = resolve(dir, basename + ext);
if (existsSync(file)) return relative(dir, file);
}
return undefined;
}
const configExts = ['.js', '.cjs', '.mjs'];
export function findConfig(dir: string, basename: string): string | undefined {
@@ -173,3 +191,78 @@ export function getRegExpFromPath(rePath: string): RegExp | false {
const re = pathToRegexp(rePath, keys);
return keys.length > 0 ? re : false;
}
/**
* Updates the `dest` process.env object to match the `source` one.
* A function is returned to restore the the `dest` env back to how
* it was originally.
*/
export function syncEnv(source: NodeJS.ProcessEnv, dest: NodeJS.ProcessEnv) {
const originalDest = { ...dest };
Object.assign(dest, source);
for (const key of Object.keys(dest)) {
if (!(key in source)) {
delete dest[key];
}
}
return () => syncEnv(originalDest, dest);
}
export async function chdirAndReadConfig(dir: string, packageJsonPath: string) {
const originalCwd = process.cwd();
// As of Remix v1.14.0, reading the config may trigger adding
// "isbot" as a dependency, and `npm`/`pnpm`/`yarn` may be invoked.
// We want to prevent that behavior, so trick `readConfig()`
// into thinking that "isbot" is already installed.
let modifiedPackageJson = false;
const pkgRaw = await fs.readFile(packageJsonPath, 'utf8');
const pkg = JSON.parse(pkgRaw);
if (!pkg.dependencies?.['isbot']) {
pkg.dependencies.isbot = 'latest';
await fs.writeFile(packageJsonPath, JSON.stringify(pkg));
modifiedPackageJson = true;
}
let remixConfig: RemixConfig;
try {
process.chdir(dir);
remixConfig = await readConfig(dir);
} finally {
process.chdir(originalCwd);
if (modifiedPackageJson) {
await fs.writeFile(packageJsonPath, pkgRaw);
}
}
return remixConfig;
}
export interface AddDependencyOptions extends SpawnOptionsExtended {
saveDev?: boolean;
}
/**
* Runs `npm i ${name}` / `pnpm i ${name}` / `yarn add ${name}`.
*/
export function addDependency(
cliType: CliType,
names: string[],
opts: AddDependencyOptions = {}
) {
const args: string[] = [];
if (cliType === 'npm' || cliType === 'pnpm') {
args.push('install');
if (opts.saveDev) {
args.push('--save-dev');
}
} else {
// 'yarn'
args.push('add');
if (opts.saveDev) {
args.push('--dev');
}
}
return spawnAsync(cliType, args.concat(names), opts);
}