mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-09 21:07:46 +00:00
[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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user