Remix Vite plugin support (#11031)

Adds support for Remix apps which use the new Remix Vite plugin.

* The vanilla Remix + Vite template deploys correctly out-of-the-box,
however only a single Node.js function will be used, and a warning will
be printed saying to configure the `vercelPreset()` Preset.
* When used in conjunction with the `vercelPreset()` Preset
(https://github.com/vercel/remix/pull/81), allows for the application to
utilize Vercel-specific features, like per-route `export const config`
configuration, including multi-runtime (Node.js / Edge runtimes) within
the same app.

## To test this today

1. Generate a Remix + Vite project from the template:
    ```
    npx create-remix@latest --template remix-run/remix/templates/vite
    ```
1. Install `@vercel/remix`:
    ```
    npm i --save-dev @vercel/remix
    ```
1. **(Before Remix v2.8.0 is released)** - Update the `@remix-run/dev`
dependency to use the "pre" tag which contains [a bug
fix](https://github.com/remix-run/remix/pull/8864):
    ```
    npm i --save--dev @remix-run/dev@pre @remix-run/serve@pre
    ```
1. Configure the `vercelPreset()` in the `vite.config.ts` file:
    ```diff
    --- a/vite.config.ts
    +++ b/vite.config.ts
    @@ -1,10 +1,11 @@
     import { vitePlugin as remix } from "@remix-run/dev";
     import { installGlobals } from "@remix-run/node";
     import { defineConfig } from "vite";
    +import { vercelPreset } from "@vercel/remix/vite";
     import tsconfigPaths from "vite-tsconfig-paths";
    
     installGlobals();
    
     export default defineConfig({
    -  plugins: [remix(), tsconfigPaths()],
    +  plugins: [remix({ presets: [vercelPreset()] }), tsconfigPaths()],
     });
    ```
1. Create a new Vercel Project in the dashboard, and ensure the Vercel
preset is set to "Remix" in the Project Settings. The autodetection will
work correctly once this PR is merged, but for now it gets incorrectly
detected as "Vite" preset.
* **Hint**: You can create a new empty Project by running the `vercel
link` command.
<img width="545" alt="Screenshot 2024-02-27 at 10 37 11"
src="https://github.com/vercel/vercel/assets/71256/f46baf57-5d97-4bde-9529-c9165632cb30">
1. Deploy to Vercel, setting the `VERCEL_CLI_VERSION` environment
variable to use the changes in this PR:
    ```
vercel deploy -b
VERCEL_CLI_VERSION=https://vercel-git-tootallnate-zero-1217-research-remix-v-next-vite.vercel.sh/tarballs/vercel.tgz
    ```
This commit is contained in:
Nathan Rajlich
2024-02-28 09:22:05 -08:00
committed by GitHub
parent c2d99855ea
commit 1333071a3a
438 changed files with 15220 additions and 839 deletions

View File

@@ -0,0 +1,5 @@
---
'@vercel/remix-builder': minor
---
Remix Vite plugin support

View File

@@ -0,0 +1,6 @@
---
'@vercel/frameworks': major
'@vercel/fs-detectors': minor
---
Make "remix" framework preset supersede "vite"

View File

@@ -38,7 +38,7 @@ packages/static-build/test/cache-fixtures
packages/redwood/test/fixtures
# remix
packages/remix/test/fixtures
packages/remix/test/fixtures-*
# gatsby-plugin-vercel-analytics
packages/gatsby-plugin-vercel-analytics

View File

@@ -29,6 +29,7 @@ turbo-cache-key.json
packages/*/dist
packages/*/node_modules
packages/**/test/fixtures
packages/**/test/fixtures-*
packages/**/test/dev/fixtures
packages/**/test/build-fixtures
packages/**/test/cache-fixtures

View File

@@ -202,11 +202,14 @@ export const frameworks = [
description: 'A new Remix app — the result of running `npx create-remix`.',
website: 'https://remix.run',
sort: 6,
supersedes: 'hydrogen',
supersedes: ['hydrogen', 'vite'],
useRuntime: { src: 'package.json', use: '@vercel/remix-builder' },
ignoreRuntimes: ['@vercel/node'],
detectors: {
some: [
{
matchPackage: '@remix-run/dev',
},
{
path: 'remix.config.js',
},
@@ -1734,7 +1737,7 @@ export const frameworks = [
tagline: 'React framework for headless commerce',
description: 'React framework for headless commerce',
website: 'https://hydrogen.shopify.dev',
supersedes: 'vite',
supersedes: ['vite'],
useRuntime: { src: 'package.json', use: '@vercel/hydrogen' },
envPrefix: 'PUBLIC_',
detectors: {

View File

@@ -220,7 +220,7 @@ export interface Framework {
*/
defaultVersion?: string;
/**
* Slug of another framework preset in which this framework supersedes.
* Array of slugs for other framework presets which this framework supersedes.
*/
supersedes?: string;
supersedes?: string[];
}

View File

@@ -199,7 +199,7 @@ const Schema = {
dependency: { type: 'string' },
cachePattern: { type: 'string' },
defaultVersion: { type: 'string' },
supersedes: { type: 'string' },
supersedes: { type: 'array', items: { type: 'string' } },
},
},
};

View File

@@ -143,7 +143,9 @@ function removeSupersededFramework(
const framework = matches[index];
if (framework) {
if (framework.supersedes) {
removeSupersededFramework(matches, framework.supersedes);
for (const slug of framework.supersedes) {
removeSupersededFramework(matches, slug);
}
}
matches.splice(index, 1);
}
@@ -154,7 +156,9 @@ export function removeSupersededFrameworks(
) {
for (const match of matches.slice()) {
if (match?.supersedes) {
removeSupersededFramework(matches, match.supersedes);
for (const slug of match.supersedes) {
removeSupersededFramework(matches, slug);
}
}
}
}

View File

@@ -166,12 +166,12 @@ describe('removeSupersededFrameworks()', () => {
const matches = [
{ slug: 'storybook' },
{ slug: 'vite' },
{ slug: 'hydrogen', supersedes: 'vite' },
{ slug: 'hydrogen', supersedes: ['vite'] },
];
removeSupersededFrameworks(matches);
expect(matches).toEqual([
{ slug: 'storybook' },
{ slug: 'hydrogen', supersedes: 'vite' },
{ slug: 'hydrogen', supersedes: ['vite'] },
]);
});
@@ -179,13 +179,13 @@ describe('removeSupersededFrameworks()', () => {
const matches = [
{ slug: 'storybook' },
{ slug: 'vite' },
{ slug: 'hydrogen', supersedes: 'vite' },
{ slug: 'remix', supersedes: 'hydrogen' },
{ slug: 'hydrogen', supersedes: ['vite'] },
{ slug: 'remix', supersedes: ['hydrogen'] },
];
removeSupersededFrameworks(matches);
expect(matches).toEqual([
{ slug: 'storybook' },
{ slug: 'remix', supersedes: 'hydrogen' },
{ slug: 'remix', supersedes: ['hydrogen'] },
]);
});
});
@@ -442,6 +442,20 @@ describe('detectFramework()', () => {
expect(await detectFramework({ fs, frameworkList })).toBe('storybook');
});
it('Should detect Remix + Vite as `remix`', async () => {
const fs = new VirtualFilesystem({
'vite.config.ts': '',
'package.json': JSON.stringify({
dependencies: {
'@remix-run/dev': 'latest',
vite: 'latest',
},
}),
});
expect(await detectFramework({ fs, frameworkList })).toBe('remix');
});
});
describe('detectFrameworks()', () => {
@@ -497,6 +511,23 @@ describe('detectFrameworks()', () => {
expect(slugs).toEqual(['nextjs', 'storybook']);
});
it('Should detect Remix + Vite as `remix`', async () => {
const fs = new VirtualFilesystem({
'vite.config.ts': '',
'package.json': JSON.stringify({
dependencies: {
'@remix-run/dev': 'latest',
vite: 'latest',
},
}),
});
const slugs = (await detectFrameworks({ fs, frameworkList })).map(
f => f.slug
);
expect(slugs).toEqual(['remix']);
});
it('Should detect "hydrogen" template as `hydrogen`', async () => {
const fs = new LocalFileSystemDetector(join(EXAMPLES_DIR, 'hydrogen'));

View File

@@ -4,6 +4,7 @@
"license": "Apache-2.0",
"main": "./dist/index.js",
"homepage": "https://vercel.com/docs",
"sideEffects": false,
"repository": {
"type": "git",
"url": "https://github.com/vercel/vercel.git",
@@ -13,7 +14,7 @@
"build": "node ../../utils/build-builder.mjs",
"test": "jest --reporters=default --reporters=jest-junit --env node --verbose --bail --runInBand",
"test-unit": "pnpm test test/unit.*test.*",
"test-e2e": "pnpm test test/integration.test.ts",
"test-e2e": "pnpm test test/integration-*.test.ts",
"type-check": "tsc --noEmit"
},
"files": [
@@ -21,6 +22,7 @@
"defaults"
],
"dependencies": {
"@vercel/error-utils": "2.0.2",
"@vercel/nft": "0.26.4",
"@vercel/static-config": "3.0.0",
"ts-morph": "12.0.0"

View File

@@ -0,0 +1,814 @@
import { Project } from 'ts-morph';
import { readFileSync, promises as fs, existsSync } from 'fs';
import { basename, dirname, extname, join, posix, relative, sep } from 'path';
import {
debug,
download,
execCommand,
FileBlob,
FileFsRef,
getEnvForPackageManager,
getNodeVersion,
getSpawnOptions,
glob,
EdgeFunction,
NodejsLambda,
rename,
runNpmInstall,
runPackageJsonScript,
scanParentDirs,
} from '@vercel/build-utils';
import { getConfig } from '@vercel/static-config';
import { nodeFileTrace } from '@vercel/nft';
import type {
BuildV2,
Files,
NodeVersion,
PackageJson,
BuildResultV2Typical,
} from '@vercel/build-utils';
import type { ConfigRoute } from '@remix-run/dev/dist/config/routes';
import type { BaseFunctionConfig } from '@vercel/static-config';
import {
calculateRouteConfigHash,
findConfig,
getPathFromRoute,
getRegExpFromPath,
getResolvedRouteConfig,
isLayoutRoute,
ResolvedRouteConfig,
ResolvedNodeRouteConfig,
ResolvedEdgeRouteConfig,
findEntry,
chdirAndReadConfig,
resolveSemverMinMax,
ensureResolvable,
isESM,
} from './utils';
import { patchHydrogenServer } from './hydrogen';
interface ServerBundle {
serverBuildPath: string;
routes: string[];
}
const remixBuilderPkg = JSON.parse(
readFileSync(join(__dirname, '../package.json'), 'utf8')
);
const remixRunDevForkVersion =
remixBuilderPkg.devDependencies['@remix-run/dev'];
const DEFAULTS_PATH = join(__dirname, '../defaults');
const edgeServerSrcPromise = fs.readFile(
join(DEFAULTS_PATH, 'server-edge.mjs'),
'utf-8'
);
const nodeServerSrcPromise = fs.readFile(
join(DEFAULTS_PATH, 'server-node.mjs'),
'utf-8'
);
// Minimum supported version of the `@vercel/remix` package
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 ({
entrypoint,
files,
workPath,
repoRootPath,
config,
meta = {},
}) => {
const { installCommand, buildCommand } = config;
await download(files, workPath, meta);
const mountpoint = dirname(entrypoint);
const entrypointFsDirname = join(workPath, mountpoint);
// Run "Install Command"
const nodeVersion = await getNodeVersion(
entrypointFsDirname,
undefined,
config,
meta
);
const { cliType, packageJsonPath, lockfileVersion, lockfilePath } =
await scanParentDirs(entrypointFsDirname);
if (!packageJsonPath) {
throw new Error('Failed to locate `package.json` file in your project');
}
const [lockfileRaw, pkgRaw] = await Promise.all([
lockfilePath ? fs.readFile(lockfilePath) : null,
fs.readFile(packageJsonPath, 'utf8'),
]);
const pkg = JSON.parse(pkgRaw);
const spawnOpts = getSpawnOptions(meta, nodeVersion);
if (!spawnOpts.env) {
spawnOpts.env = {};
}
spawnOpts.env = getEnvForPackageManager({
cliType,
lockfileVersion,
nodeVersion,
env: spawnOpts.env,
});
if (typeof installCommand === 'string') {
if (installCommand.trim()) {
console.log(`Running "install" command: \`${installCommand}\`...`);
await execCommand(installCommand, {
...spawnOpts,
cwd: entrypointFsDirname,
});
} else {
console.log(`Skipping "install" command...`);
}
} else {
await runNpmInstall(entrypointFsDirname, [], spawnOpts, meta, nodeVersion);
}
const isHydrogen2 = Boolean(
pkg.dependencies?.['@shopify/remix-oxygen'] ||
pkg.devDependencies?.['@shopify/remix-oxygen']
);
// Determine the version of Remix based on the `@remix-run/dev`
// package version.
const remixRunDevPath = await ensureResolvable(
entrypointFsDirname,
repoRootPath,
'@remix-run/dev'
);
const remixRunDevPkg = JSON.parse(
readFileSync(join(remixRunDevPath, 'package.json'), 'utf8')
);
const remixVersion = remixRunDevPkg.version;
const remixConfig = await chdirAndReadConfig(
remixRunDevPath,
entrypointFsDirname,
packageJsonPath
);
const { serverEntryPoint, appDirectory } = remixConfig;
const remixRoutes = Object.values(remixConfig.routes);
let depsModified = false;
const remixRunDevPkgVersion: string | undefined =
pkg.dependencies?.['@remix-run/dev'] ||
pkg.devDependencies?.['@remix-run/dev'];
const serverBundlesMap = new Map<string, ConfigRoute[]>();
const resolvedConfigsMap = new Map<ConfigRoute, ResolvedRouteConfig>();
// Read the `export const config` (if any) for each route
const project = new Project();
const staticConfigsMap = new Map<ConfigRoute, BaseFunctionConfig | null>();
for (const route of remixRoutes) {
const routePath = join(remixConfig.appDirectory, route.file);
let staticConfig = getConfig(project, routePath);
if (staticConfig && isHydrogen2) {
console.log(
'WARN: `export const config` is currently not supported for Hydrogen v2 apps'
);
staticConfig = null;
}
staticConfigsMap.set(route, staticConfig);
}
for (const route of remixRoutes) {
const config = getResolvedRouteConfig(
route,
remixConfig.routes,
staticConfigsMap,
isHydrogen2
);
resolvedConfigsMap.set(route, config);
}
// Figure out which routes belong to which server bundles
// based on having common static config properties
for (const route of remixRoutes) {
if (isLayoutRoute(route.id, remixRoutes)) continue;
const config = resolvedConfigsMap.get(route);
if (!config) {
throw new Error(`Expected resolved config for "${route.id}"`);
}
const hash = calculateRouteConfigHash(config);
let routesForHash = serverBundlesMap.get(hash);
if (!Array.isArray(routesForHash)) {
routesForHash = [];
serverBundlesMap.set(hash, routesForHash);
}
routesForHash.push(route);
}
let serverBundles: ServerBundle[] = Array.from(
serverBundlesMap.entries()
).map(([hash, routes]) => {
const runtime = resolvedConfigsMap.get(routes[0])?.runtime ?? 'nodejs';
return {
serverBuildPath: isHydrogen2
? relative(entrypointFsDirname, remixConfig.serverBuildPath)
: `${relative(
entrypointFsDirname,
dirname(remixConfig.serverBuildPath)
)}/build-${runtime}-${hash}.js`,
routes: routes.map(r => r.id),
};
});
// If the project is *not* relying on split configurations, then set
// the `serverBuildPath` to the default Remix path, since the forked
// Remix compiler will not be used
if (!isHydrogen2 && serverBundles.length === 1) {
// `serverBuildTarget` and `serverBuildPath` are undefined with
// our remix config modifications, so use the default build path
serverBundles[0].serverBuildPath = 'build/index.js';
}
// If the project is relying on split configurations, then override
// the official `@remix-run/dev` package with the Vercel fork,
// which supports the `serverBundles` config
if (
serverBundles.length > 1 &&
!isHydrogen2 &&
remixRunDevPkg.name !== '@vercel/remix-run-dev' &&
!remixRunDevPkgVersion?.startsWith('https:')
) {
const remixDevForkVersion = resolveSemverMinMax(
REMIX_RUN_DEV_MIN_VERSION,
REMIX_RUN_DEV_MAX_VERSION,
remixVersion
);
// Remove `@remix-run/dev`, add `@vercel/remix-run-dev`
if (pkg.devDependencies['@remix-run/dev']) {
delete pkg.devDependencies['@remix-run/dev'];
pkg.devDependencies['@vercel/remix-run-dev'] = remixDevForkVersion;
} else {
delete pkg.dependencies['@remix-run/dev'];
pkg.dependencies['@vercel/remix-run-dev'] = remixDevForkVersion;
}
depsModified = true;
}
// `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']) {
// Dependency version resolution logic
// 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 known max
// 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
// the latest known published version of `@vercel/remix`.
const vercelRemixVersion = resolveSemverMinMax(
VERCEL_REMIX_MIN_VERSION,
REMIX_RUN_DEV_MAX_VERSION,
remixVersion
);
pkg.dependencies['@vercel/remix'] = vercelRemixVersion;
depsModified = true;
}
}
if (depsModified) {
await fs.writeFile(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
// Bypass `--frozen-lockfile` enforcement by removing
// env vars that are considered to be CI
const nonCiEnv = { ...spawnOpts.env };
delete nonCiEnv.CI;
delete nonCiEnv.VERCEL;
delete nonCiEnv.NOW_BUILDER;
// Purposefully not passing `meta` here to avoid
// the optimization that prevents `npm install`
// from running a second time
await runNpmInstall(
entrypointFsDirname,
[],
{
...spawnOpts,
env: nonCiEnv,
},
undefined,
nodeVersion
);
}
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;
let serverEntryPointAbs: string | undefined;
let originalServerEntryPoint: string | undefined;
const remixConfigPath = findConfig(entrypointFsDirname, 'remix.config');
const renamedRemixConfigPath = remixConfigPath
? `${remixConfigPath}.original${extname(remixConfigPath)}`
: undefined;
try {
// We need to patch the `remix.config.js` file to force some values necessary
// for a build that works on either Node.js or the Edge runtime
if (!isHydrogen2 && remixConfigPath && renamedRemixConfigPath) {
await fs.rename(remixConfigPath, renamedRemixConfigPath);
let patchedConfig: string;
// Figure out if the `remix.config` file is using ESM syntax
if (isESM(renamedRemixConfigPath)) {
patchedConfig = `import config from './${basename(
renamedRemixConfigPath
)}';
config.serverBuildTarget = undefined;
config.serverModuleFormat = '${pkg.type === 'module' ? 'esm' : 'cjs'}';
config.serverPlatform = 'node';
config.serverBuildPath = undefined;
config.serverBundles = ${JSON.stringify(serverBundles)};
export default config;`;
} else {
patchedConfig = `const config = require('./${basename(
renamedRemixConfigPath
)}');
config.serverBuildTarget = undefined;
config.serverModuleFormat = '${pkg.type === 'module' ? 'esm' : 'cjs'}';
config.serverPlatform = 'node';
config.serverBuildPath = undefined;
config.serverBundles = ${JSON.stringify(serverBundles)};
module.exports = config;`;
}
await fs.writeFile(remixConfigPath, patchedConfig);
remixConfigWrapped = true;
}
// For Hydrogen v2, patch the `server.ts` file to be Vercel-compatible
if (isHydrogen2) {
if (remixConfig.serverEntryPoint) {
serverEntryPointAbs = join(
entrypointFsDirname,
remixConfig.serverEntryPoint
);
originalServerEntryPoint = await fs.readFile(
serverEntryPointAbs,
'utf8'
);
const patchedServerEntryPoint = patchHydrogenServer(
project,
serverEntryPointAbs
);
if (patchedServerEntryPoint) {
debug(
`Patched Hydrogen server file: ${remixConfig.serverEntryPoint}`
);
await fs.writeFile(serverEntryPointAbs, patchedServerEntryPoint);
}
} else {
console.log('WARN: No "server" field found in Remix config');
}
}
// Make `remix build` output production mode
spawnOpts.env.NODE_ENV = 'production';
// Run "Build Command"
if (buildCommand) {
debug(`Executing build command "${buildCommand}"`);
await execCommand(buildCommand, {
...spawnOpts,
cwd: entrypointFsDirname,
});
} else {
if (hasScript('vercel-build', pkg)) {
debug(`Executing "yarn vercel-build"`);
await runPackageJsonScript(
entrypointFsDirname,
'vercel-build',
spawnOpts
);
} else if (hasScript('build', pkg)) {
debug(`Executing "yarn build"`);
await runPackageJsonScript(entrypointFsDirname, 'build', spawnOpts);
} else {
await execCommand('remix build', {
...spawnOpts,
cwd: entrypointFsDirname,
});
}
}
} finally {
const cleanupOps: Promise<void>[] = [];
// Clean up our patched `remix.config.js` to be polite
if (remixConfigWrapped && remixConfigPath && renamedRemixConfigPath) {
cleanupOps.push(
fs
.rename(renamedRemixConfigPath, remixConfigPath)
.then(() => debug(`Restored original "${remixConfigPath}" file`))
);
}
// Restore original server entrypoint if it was modified (for Hydrogen v2)
if (serverEntryPointAbs && originalServerEntryPoint) {
cleanupOps.push(
fs
.writeFile(serverEntryPointAbs, originalServerEntryPoint)
.then(() => debug(`Restored original "${serverEntryPointAbs}" file`))
);
}
// Restore original `package.json` file and lockfile
if (depsModified) {
cleanupOps.push(
fs
.writeFile(packageJsonPath, pkgRaw)
.then(() => debug(`Restored original "${packageJsonPath}" file`))
);
if (lockfilePath && lockfileRaw) {
cleanupOps.push(
fs
.writeFile(lockfilePath, lockfileRaw)
.then(() => debug(`Restored original "${lockfilePath}" file`))
);
}
}
await Promise.all(cleanupOps);
}
// This needs to happen before we run NFT to create the Node/Edge functions
await Promise.all([
ensureResolvable(
entrypointFsDirname,
repoRootPath,
'@remix-run/server-runtime'
),
!isHydrogen2
? ensureResolvable(entrypointFsDirname, repoRootPath, '@remix-run/node')
: null,
]);
const staticDir = join(entrypointFsDirname, 'public');
// Do a sanity check to ensure that the server bundles `serverBuildPath` was actually created.
// If it was not, then that usually means the Vercel forked Remix compiler was not used and
// thus only a singular server bundle was produced.
const serverBundlesRespected = existsSync(
join(entrypointFsDirname, serverBundles[0].serverBuildPath)
);
if (!serverBundlesRespected) {
console.warn(
'WARN: `serverBundles` configuration failed. Falling back to a singular server bundle.'
);
serverBundles = [
{
serverBuildPath: 'build/index.js',
routes: serverBundles.flatMap(b => b.routes),
},
];
}
const [staticFiles, buildAssets, ...functions] = await Promise.all([
glob('**', staticDir),
glob('**', remixConfig.assetsBuildDirectory),
...serverBundles.map(bundle => {
const firstRoute = remixConfig.routes[bundle.routes[0]];
const config = resolvedConfigsMap.get(firstRoute) ?? {
runtime: 'nodejs',
};
const serverBuildPath = join(entrypointFsDirname, bundle.serverBuildPath);
if (config.runtime === 'edge') {
return createRenderEdgeFunction(
entrypointFsDirname,
repoRootPath,
serverBuildPath,
serverEntryPoint,
remixVersion,
config
);
}
return createRenderNodeFunction(
nodeVersion,
entrypointFsDirname,
repoRootPath,
serverBuildPath,
serverEntryPoint,
remixVersion,
config
);
}),
]);
const transformedBuildAssets = rename(buildAssets, name => {
return posix.join('./', remixConfig.publicPath, name);
});
const output: BuildResultV2Typical['output'] = {
...staticFiles,
...transformedBuildAssets,
};
const routes: any[] = [
{
src: `^/${remixConfig.publicPath.replace(/^\/|\/$/g, '')}/(.*)$`,
headers: { 'cache-control': 'public, max-age=31536000, immutable' },
continue: true,
},
{
handle: 'filesystem',
},
];
for (const route of remixRoutes) {
// Layout routes don't get a function / route added
if (isLayoutRoute(route.id, remixRoutes)) continue;
const { path, rePath } = getPathFromRoute(route, remixConfig.routes);
// If the route is a pathless layout route (at the root level)
// and doesn't have any sub-routes, then a function should not be created.
if (!path) {
continue;
}
const funcIndex = serverBundles.findIndex(bundle => {
return bundle.routes.includes(route.id);
});
const func = functions[funcIndex];
if (!func) {
throw new Error(`Could not determine server bundle for "${route.id}"`);
}
output[path] = func;
// If this is a dynamic route then add a Vercel route
const re = getRegExpFromPath(rePath);
if (re) {
routes.push({
src: re.source,
dest: path,
});
}
}
// Add a 404 path for not found pages to be server-side rendered by Remix.
// Use an edge function bundle if one was generated, otherwise use Node.js.
if (!output['404']) {
const edgeFunctionIndex = Array.from(serverBundlesMap.values()).findIndex(
routes => {
const runtime = resolvedConfigsMap.get(routes[0])?.runtime;
return runtime === 'edge';
}
);
const func =
edgeFunctionIndex !== -1 ? functions[edgeFunctionIndex] : functions[0];
output['404'] = func;
}
routes.push({
src: '/(.*)',
dest: '/404',
});
return { routes, output, framework: { version: remixVersion } };
};
function hasScript(scriptName: string, pkg: PackageJson | null) {
const scripts = (pkg && pkg.scripts) || {};
return typeof scripts[scriptName] === 'string';
}
async function createRenderNodeFunction(
nodeVersion: NodeVersion,
entrypointDir: string,
rootDir: string,
serverBuildPath: string,
serverEntryPoint: string | undefined,
remixVersion: string,
config: ResolvedNodeRouteConfig
): Promise<NodejsLambda> {
const files: Files = {};
let handler = relative(rootDir, serverBuildPath);
let handlerPath = join(rootDir, handler);
if (!serverEntryPoint) {
const baseServerBuildPath = basename(serverBuildPath, '.js');
handler = join(dirname(handler), `server-${baseServerBuildPath}.mjs`);
handlerPath = join(rootDir, handler);
// Copy the `server-node.mjs` file into the "build" directory
const nodeServerSrc = await nodeServerSrcPromise;
await writeEntrypointFile(
handlerPath,
nodeServerSrc.replace(
'@remix-run/dev/server-build',
`./${baseServerBuildPath}.js`
),
rootDir
);
}
// Trace the handler with `@vercel/nft`
const trace = await nodeFileTrace([handlerPath], {
base: rootDir,
processCwd: entrypointDir,
});
for (const warning of trace.warnings) {
debug(`Warning from trace: ${warning.message}`);
}
for (const file of trace.fileList) {
files[file] = await FileFsRef.fromFsPath({ fsPath: join(rootDir, file) });
}
const fn = new NodejsLambda({
files,
handler,
runtime: nodeVersion.runtime,
shouldAddHelpers: false,
shouldAddSourcemapSupport: false,
operationType: 'SSR',
supportsResponseStreaming: true,
regions: config.regions,
memory: config.memory,
maxDuration: config.maxDuration,
framework: {
slug: 'remix',
version: remixVersion,
},
});
return fn;
}
async function createRenderEdgeFunction(
entrypointDir: string,
rootDir: string,
serverBuildPath: string,
serverEntryPoint: string | undefined,
remixVersion: string,
config: ResolvedEdgeRouteConfig
): Promise<EdgeFunction> {
const files: Files = {};
let handler = relative(rootDir, serverBuildPath);
let handlerPath = join(rootDir, handler);
if (!serverEntryPoint) {
const baseServerBuildPath = basename(serverBuildPath, '.js');
handler = join(dirname(handler), `server-${baseServerBuildPath}.mjs`);
handlerPath = join(rootDir, handler);
// Copy the `server-edge.mjs` file into the "build" directory
const edgeServerSrc = await edgeServerSrcPromise;
await writeEntrypointFile(
handlerPath,
edgeServerSrc.replace(
'@remix-run/dev/server-build',
`./${baseServerBuildPath}.js`
),
rootDir
);
}
let remixRunVercelPkgJson: string | undefined;
// Trace the handler with `@vercel/nft`
const trace = await nodeFileTrace([handlerPath], {
base: rootDir,
processCwd: entrypointDir,
conditions: ['edge-light', 'browser', 'module', 'import', 'require'],
async readFile(fsPath) {
let source: Buffer | string;
try {
source = await fs.readFile(fsPath);
} catch (err: any) {
if (err.code === 'ENOENT' || err.code === 'EISDIR') {
return null;
}
throw err;
}
if (basename(fsPath) === 'package.json') {
// For Edge Functions, patch "main" field to prefer "browser" or "module"
const pkgJson = JSON.parse(source.toString());
// When `@remix-run/vercel` is detected, we need to modify the `package.json`
// to include the "browser" field so that the proper Edge entrypoint file
// is used. This is a temporary stop gap until this PR is merged:
// https://github.com/remix-run/remix/pull/5537
if (pkgJson.name === '@remix-run/vercel') {
pkgJson.browser = 'dist/edge.js';
pkgJson.dependencies['@remix-run/server-runtime'] =
pkgJson.dependencies['@remix-run/node'];
if (!remixRunVercelPkgJson) {
remixRunVercelPkgJson = JSON.stringify(pkgJson, null, 2) + '\n';
// Copy in the edge entrypoint so that NFT can properly resolve it
const vercelEdgeEntrypointPath = join(
DEFAULTS_PATH,
'vercel-edge-entrypoint.js'
);
const vercelEdgeEntrypointDest = join(
dirname(fsPath),
'dist/edge.js'
);
await fs.copyFile(
vercelEdgeEntrypointPath,
vercelEdgeEntrypointDest
);
}
}
for (const prop of ['browser', 'module']) {
const val = pkgJson[prop];
if (typeof val === 'string') {
pkgJson.main = val;
// Return the modified `package.json` to nft
source = JSON.stringify(pkgJson);
break;
}
}
}
return source;
},
});
for (const warning of trace.warnings) {
debug(`Warning from trace: ${warning.message}`);
}
for (const file of trace.fileList) {
if (
remixRunVercelPkgJson &&
file.endsWith(`@remix-run${sep}vercel${sep}package.json`)
) {
// Use the modified `@remix-run/vercel` package.json which contains "browser" field
files[file] = new FileBlob({ data: remixRunVercelPkgJson });
} else {
files[file] = await FileFsRef.fromFsPath({ fsPath: join(rootDir, file) });
}
}
const fn = new EdgeFunction({
files,
deploymentTarget: 'v8-worker',
entrypoint: handler,
regions: config.regions,
framework: {
slug: 'remix',
version: remixVersion,
},
});
return fn;
}
async function writeEntrypointFile(
path: string,
data: string,
rootDir: string
) {
try {
await fs.writeFile(path, data);
} catch (err: any) {
if (err.code === 'ENOENT') {
throw new Error(
`The "${relative(
rootDir,
dirname(path)
)}" directory does not exist. Please contact support at https://vercel.com/help.`
);
}
throw err;
}
}

View File

@@ -0,0 +1,477 @@
import { readFileSync, promises as fs, statSync, existsSync } from 'fs';
import { basename, dirname, join, relative, sep } from 'path';
import { isErrnoException } from '@vercel/error-utils';
import { nodeFileTrace } from '@vercel/nft';
import {
BuildResultV2Typical,
debug,
execCommand,
getEnvForPackageManager,
getNodeVersion,
getSpawnOptions,
glob,
runNpmInstall,
runPackageJsonScript,
scanParentDirs,
FileBlob,
FileFsRef,
EdgeFunction,
NodejsLambda,
} from '@vercel/build-utils';
import {
ensureResolvable,
getPathFromRoute,
getRegExpFromPath,
hasScript,
} from './utils';
import type { BuildV2, Files, NodeVersion } from '@vercel/build-utils';
const DEFAULTS_PATH = join(__dirname, '../defaults');
const edgeServerSrcPromise = fs.readFile(
join(DEFAULTS_PATH, 'server-edge.mjs'),
'utf-8'
);
const nodeServerSrcPromise = fs.readFile(
join(DEFAULTS_PATH, 'server-node.mjs'),
'utf-8'
);
interface RemixBuildResult {
buildManifest: {
serverBundles?: Record<
string,
{ id: string; file: string; config: Record<string, unknown> }
>;
routeIdToServerBundleId?: Record<string, string>;
routes: Record<
string,
{
id: string;
file: string;
path?: string;
index?: boolean;
parentId?: string;
config: Record<string, unknown>;
}
>;
};
remixConfig: {
buildDirectory: string;
};
viteConfig?: {
build?: {
assetsDir: string;
};
};
}
export const build: BuildV2 = async ({
entrypoint,
workPath,
repoRootPath,
config,
meta = {},
}) => {
const { installCommand, buildCommand } = config;
const mountpoint = dirname(entrypoint);
const entrypointFsDirname = join(workPath, mountpoint);
// Run "Install Command"
const nodeVersion = await getNodeVersion(
entrypointFsDirname,
undefined,
config,
meta
);
const { cliType, lockfileVersion, packageJson } = await scanParentDirs(
entrypointFsDirname,
true
);
const spawnOpts = getSpawnOptions(meta, nodeVersion);
if (!spawnOpts.env) {
spawnOpts.env = {};
}
spawnOpts.env = getEnvForPackageManager({
cliType,
lockfileVersion,
nodeVersion,
env: spawnOpts.env,
});
if (typeof installCommand === 'string') {
if (installCommand.trim()) {
console.log(`Running "install" command: \`${installCommand}\`...`);
await execCommand(installCommand, {
...spawnOpts,
cwd: entrypointFsDirname,
});
} else {
console.log(`Skipping "install" command...`);
}
} else {
await runNpmInstall(entrypointFsDirname, [], spawnOpts, meta, nodeVersion);
}
// Determine the version of Remix based on the `@remix-run/dev`
// package version.
const remixRunDevPath = await ensureResolvable(
entrypointFsDirname,
repoRootPath,
'@remix-run/dev'
);
const remixRunDevPkg = JSON.parse(
readFileSync(join(remixRunDevPath, 'package.json'), 'utf8')
);
const remixVersion = remixRunDevPkg.version;
// Make `remix build` output production mode
spawnOpts.env.NODE_ENV = 'production';
// Run "Build Command"
if (buildCommand) {
debug(`Executing build command "${buildCommand}"`);
await execCommand(buildCommand, {
...spawnOpts,
cwd: entrypointFsDirname,
});
} else {
if (hasScript('vercel-build', packageJson)) {
debug(`Executing "vercel-build" script`);
await runPackageJsonScript(
entrypointFsDirname,
'vercel-build',
spawnOpts
);
} else if (hasScript('build', packageJson)) {
debug(`Executing "build" script`);
await runPackageJsonScript(entrypointFsDirname, 'build', spawnOpts);
} else {
await execCommand('remix build', {
...spawnOpts,
cwd: entrypointFsDirname,
});
}
}
// This needs to happen before we run NFT to create the Node/Edge functions
// TODO: maybe remove this?
await Promise.all([
ensureResolvable(
entrypointFsDirname,
repoRootPath,
'@remix-run/server-runtime'
),
ensureResolvable(entrypointFsDirname, repoRootPath, '@remix-run/node'),
]);
const remixBuildResultPath = join(
entrypointFsDirname,
'.vercel/remix-build-result.json'
);
let remixBuildResult: RemixBuildResult | undefined;
try {
const remixBuildResultContents = readFileSync(remixBuildResultPath, 'utf8');
remixBuildResult = JSON.parse(remixBuildResultContents);
} catch (err: unknown) {
if (!isErrnoException(err) || err.code !== 'ENOENT') {
throw err;
}
// The project has not configured the `vercelPreset()`
// Preset in the "vite.config" file. Attempt to check
// for the default build output directory.
const buildDirectory = join(entrypointFsDirname, 'build');
if (statSync(buildDirectory).isDirectory()) {
console.warn('WARN: The `vercelPreset()` Preset was not detected.');
remixBuildResult = {
buildManifest: {
routes: {
root: {
path: '',
id: 'root',
file: 'app/root.tsx',
config: {},
},
'routes/_index': {
file: 'app/routes/_index.tsx',
id: 'routes/_index',
index: true,
parentId: 'root',
config: {},
},
},
},
remixConfig: {
buildDirectory,
},
};
// Detect if a server build exists (won't be the case when `ssr: false`)
const serverPath = 'build/server/index.js';
if (existsSync(join(entrypointFsDirname, serverPath))) {
remixBuildResult.buildManifest.routeIdToServerBundleId = {
'routes/_index': '',
};
remixBuildResult.buildManifest.serverBundles = {
'': {
id: '',
file: serverPath,
config: {},
},
};
}
}
}
if (!remixBuildResult) {
throw new Error(
'Could not determine build output directory. Please configure the `vercelPreset()` Preset from the `@vercel/remix` npm package'
);
}
const { buildManifest, remixConfig, viteConfig } = remixBuildResult;
const staticDir = join(remixConfig.buildDirectory, 'client');
const serverBundles = Object.values(buildManifest.serverBundles ?? {});
const [staticFiles, ...functions] = await Promise.all([
glob('**', staticDir),
...serverBundles.map(bundle => {
if (bundle.config.runtime === 'edge') {
return createRenderEdgeFunction(
entrypointFsDirname,
repoRootPath,
join(entrypointFsDirname, bundle.file),
undefined,
remixVersion,
bundle.config
);
}
return createRenderNodeFunction(
nodeVersion,
entrypointFsDirname,
repoRootPath,
join(entrypointFsDirname, bundle.file),
undefined,
remixVersion,
bundle.config
);
}),
]);
const functionsMap = new Map<string, EdgeFunction | NodejsLambda>();
for (let i = 0; i < serverBundles.length; i++) {
functionsMap.set(serverBundles[i].id, functions[i]);
}
const output: BuildResultV2Typical['output'] = staticFiles;
const assetsDir = viteConfig?.build?.assetsDir || 'assets';
const routes: any[] = [
{
src: `^/${assetsDir}/(.*)$`,
headers: { 'cache-control': 'public, max-age=31536000, immutable' },
continue: true,
},
{
handle: 'filesystem',
},
];
for (const [id, functionId] of Object.entries(
buildManifest.routeIdToServerBundleId ?? {}
)) {
const route = buildManifest.routes[id];
const { path, rePath } = getPathFromRoute(route, buildManifest.routes);
// If the route is a pathless layout route (at the root level)
// and doesn't have any sub-routes, then a function should not be created.
if (!path) {
continue;
}
const func = functionsMap.get(functionId);
if (!func) {
throw new Error(`Could not determine server bundle for "${id}"`);
}
output[path] = func;
// If this is a dynamic route then add a Vercel route
const re = getRegExpFromPath(rePath);
if (re) {
routes.push({
src: re.source,
dest: path,
});
}
}
// For the 404 case, invoke the Function (or serve the static file
// for `ssr: false` mode) at the `/` path. Remix will serve its 404 route.
routes.push({
src: '/(.*)',
dest: '/',
});
return { routes, output, framework: { version: remixVersion } };
};
async function createRenderNodeFunction(
nodeVersion: NodeVersion,
entrypointDir: string,
rootDir: string,
serverBuildPath: string,
serverEntryPoint: string | undefined,
remixVersion: string,
config: /*TODO: ResolvedNodeRouteConfig*/ any
): Promise<NodejsLambda> {
const files: Files = {};
let handler = relative(rootDir, serverBuildPath);
let handlerPath = join(rootDir, handler);
if (!serverEntryPoint) {
const baseServerBuildPath = basename(serverBuildPath, '.js');
handler = join(dirname(handler), `server-${baseServerBuildPath}.mjs`);
handlerPath = join(rootDir, handler);
// Copy the `server-node.mjs` file into the "build" directory
const nodeServerSrc = await nodeServerSrcPromise;
await fs.writeFile(
handlerPath,
nodeServerSrc.replace(
'@remix-run/dev/server-build',
`./${baseServerBuildPath}.js`
)
);
}
// Trace the handler with `@vercel/nft`
const trace = await nodeFileTrace([handlerPath], {
base: rootDir,
processCwd: entrypointDir,
});
for (const warning of trace.warnings) {
debug(`Warning from trace: ${warning.message}`);
}
for (const file of trace.fileList) {
files[file] = await FileFsRef.fromFsPath({ fsPath: join(rootDir, file) });
}
const fn = new NodejsLambda({
files,
handler,
runtime: nodeVersion.runtime,
shouldAddHelpers: false,
shouldAddSourcemapSupport: false,
operationType: 'SSR',
supportsResponseStreaming: true,
regions: config.regions,
memory: config.memory,
maxDuration: config.maxDuration,
framework: {
slug: 'remix',
version: remixVersion,
},
});
return fn;
}
async function createRenderEdgeFunction(
entrypointDir: string,
rootDir: string,
serverBuildPath: string,
serverEntryPoint: string | undefined,
remixVersion: string,
config: /* TODO: ResolvedEdgeRouteConfig*/ any
): Promise<EdgeFunction> {
const files: Files = {};
let handler = relative(rootDir, serverBuildPath);
let handlerPath = join(rootDir, handler);
if (!serverEntryPoint) {
const baseServerBuildPath = basename(serverBuildPath, '.js');
handler = join(dirname(handler), `server-${baseServerBuildPath}.mjs`);
handlerPath = join(rootDir, handler);
// Copy the `server-edge.mjs` file into the "build" directory
const edgeServerSrc = await edgeServerSrcPromise;
await fs.writeFile(
handlerPath,
edgeServerSrc.replace(
'@remix-run/dev/server-build',
`./${baseServerBuildPath}.js`
)
);
}
let remixRunVercelPkgJson: string | undefined;
// Trace the handler with `@vercel/nft`
const trace = await nodeFileTrace([handlerPath], {
base: rootDir,
processCwd: entrypointDir,
conditions: ['edge-light', 'browser', 'module', 'import', 'require'],
async readFile(fsPath) {
let source: Buffer | string;
try {
source = await fs.readFile(fsPath);
} catch (err: any) {
if (err.code === 'ENOENT' || err.code === 'EISDIR') {
return null;
}
throw err;
}
if (basename(fsPath) === 'package.json') {
// For Edge Functions, patch "main" field to prefer "browser" or "module"
const pkgJson = JSON.parse(source.toString());
for (const prop of ['browser', 'module']) {
const val = pkgJson[prop];
if (typeof val === 'string') {
pkgJson.main = val;
// Return the modified `package.json` to nft
source = JSON.stringify(pkgJson);
break;
}
}
}
return source;
},
});
for (const warning of trace.warnings) {
debug(`Warning from trace: ${warning.message}`);
}
for (const file of trace.fileList) {
if (
remixRunVercelPkgJson &&
file.endsWith(`@remix-run${sep}vercel${sep}package.json`)
) {
// Use the modified `@remix-run/vercel` package.json which contains "browser" field
files[file] = new FileBlob({ data: remixRunVercelPkgJson });
} else {
files[file] = await FileFsRef.fromFsPath({ fsPath: join(rootDir, file) });
}
}
const fn = new EdgeFunction({
files,
deploymentTarget: 'v8-worker',
entrypoint: handler,
regions: config.regions,
framework: {
slug: 'remix',
version: remixVersion,
},
});
return fn;
}

View File

@@ -1,814 +1,9 @@
import { Project } from 'ts-morph';
import { readFileSync, promises as fs, existsSync } from 'fs';
import { basename, dirname, extname, join, posix, relative, sep } from 'path';
import {
debug,
download,
execCommand,
FileBlob,
FileFsRef,
getEnvForPackageManager,
getNodeVersion,
getSpawnOptions,
glob,
EdgeFunction,
NodejsLambda,
rename,
runNpmInstall,
runPackageJsonScript,
scanParentDirs,
} from '@vercel/build-utils';
import { getConfig } from '@vercel/static-config';
import { nodeFileTrace } from '@vercel/nft';
import type {
BuildV2,
Files,
NodeVersion,
PackageJson,
BuildResultV2Typical,
} from '@vercel/build-utils';
import type { ConfigRoute } from '@remix-run/dev/dist/config/routes';
import type { BaseFunctionConfig } from '@vercel/static-config';
import {
calculateRouteConfigHash,
findConfig,
getPathFromRoute,
getRegExpFromPath,
getResolvedRouteConfig,
isLayoutRoute,
ResolvedRouteConfig,
ResolvedNodeRouteConfig,
ResolvedEdgeRouteConfig,
findEntry,
chdirAndReadConfig,
resolveSemverMinMax,
ensureResolvable,
isESM,
} from './utils';
import { patchHydrogenServer } from './hydrogen';
import { build as buildVite } from './build-vite';
import { build as buildLegacy } from './build-legacy';
import { findConfig } from './utils';
import type { BuildV2 } from '@vercel/build-utils';
interface ServerBundle {
serverBuildPath: string;
routes: string[];
}
const remixBuilderPkg = JSON.parse(
readFileSync(join(__dirname, '../package.json'), 'utf8')
);
const remixRunDevForkVersion =
remixBuilderPkg.devDependencies['@remix-run/dev'];
const DEFAULTS_PATH = join(__dirname, '../defaults');
const edgeServerSrcPromise = fs.readFile(
join(DEFAULTS_PATH, 'server-edge.mjs'),
'utf-8'
);
const nodeServerSrcPromise = fs.readFile(
join(DEFAULTS_PATH, 'server-node.mjs'),
'utf-8'
);
// Minimum supported version of the `@vercel/remix` package
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 ({
entrypoint,
files,
workPath,
repoRootPath,
config,
meta = {},
}) => {
const { installCommand, buildCommand } = config;
await download(files, workPath, meta);
const mountpoint = dirname(entrypoint);
const entrypointFsDirname = join(workPath, mountpoint);
// Run "Install Command"
const nodeVersion = await getNodeVersion(
entrypointFsDirname,
undefined,
config,
meta
);
const { cliType, packageJsonPath, lockfileVersion, lockfilePath } =
await scanParentDirs(entrypointFsDirname);
if (!packageJsonPath) {
throw new Error('Failed to locate `package.json` file in your project');
}
const [lockfileRaw, pkgRaw] = await Promise.all([
lockfilePath ? fs.readFile(lockfilePath) : null,
fs.readFile(packageJsonPath, 'utf8'),
]);
const pkg = JSON.parse(pkgRaw);
const spawnOpts = getSpawnOptions(meta, nodeVersion);
if (!spawnOpts.env) {
spawnOpts.env = {};
}
spawnOpts.env = getEnvForPackageManager({
cliType,
lockfileVersion,
nodeVersion,
env: spawnOpts.env,
});
if (typeof installCommand === 'string') {
if (installCommand.trim()) {
console.log(`Running "install" command: \`${installCommand}\`...`);
await execCommand(installCommand, {
...spawnOpts,
cwd: entrypointFsDirname,
});
} else {
console.log(`Skipping "install" command...`);
}
} else {
await runNpmInstall(entrypointFsDirname, [], spawnOpts, meta, nodeVersion);
}
const isHydrogen2 = Boolean(
pkg.dependencies?.['@shopify/remix-oxygen'] ||
pkg.devDependencies?.['@shopify/remix-oxygen']
);
// Determine the version of Remix based on the `@remix-run/dev`
// package version.
const remixRunDevPath = await ensureResolvable(
entrypointFsDirname,
repoRootPath,
'@remix-run/dev'
);
const remixRunDevPkg = JSON.parse(
readFileSync(join(remixRunDevPath, 'package.json'), 'utf8')
);
const remixVersion = remixRunDevPkg.version;
const remixConfig = await chdirAndReadConfig(
remixRunDevPath,
entrypointFsDirname,
packageJsonPath
);
const { serverEntryPoint, appDirectory } = remixConfig;
const remixRoutes = Object.values(remixConfig.routes);
let depsModified = false;
const remixRunDevPkgVersion: string | undefined =
pkg.dependencies?.['@remix-run/dev'] ||
pkg.devDependencies?.['@remix-run/dev'];
const serverBundlesMap = new Map<string, ConfigRoute[]>();
const resolvedConfigsMap = new Map<ConfigRoute, ResolvedRouteConfig>();
// Read the `export const config` (if any) for each route
const project = new Project();
const staticConfigsMap = new Map<ConfigRoute, BaseFunctionConfig | null>();
for (const route of remixRoutes) {
const routePath = join(remixConfig.appDirectory, route.file);
let staticConfig = getConfig(project, routePath);
if (staticConfig && isHydrogen2) {
console.log(
'WARN: `export const config` is currently not supported for Hydrogen v2 apps'
);
staticConfig = null;
}
staticConfigsMap.set(route, staticConfig);
}
for (const route of remixRoutes) {
const config = getResolvedRouteConfig(
route,
remixConfig.routes,
staticConfigsMap,
isHydrogen2
);
resolvedConfigsMap.set(route, config);
}
// Figure out which routes belong to which server bundles
// based on having common static config properties
for (const route of remixRoutes) {
if (isLayoutRoute(route.id, remixRoutes)) continue;
const config = resolvedConfigsMap.get(route);
if (!config) {
throw new Error(`Expected resolved config for "${route.id}"`);
}
const hash = calculateRouteConfigHash(config);
let routesForHash = serverBundlesMap.get(hash);
if (!Array.isArray(routesForHash)) {
routesForHash = [];
serverBundlesMap.set(hash, routesForHash);
}
routesForHash.push(route);
}
let serverBundles: ServerBundle[] = Array.from(
serverBundlesMap.entries()
).map(([hash, routes]) => {
const runtime = resolvedConfigsMap.get(routes[0])?.runtime ?? 'nodejs';
return {
serverBuildPath: isHydrogen2
? relative(entrypointFsDirname, remixConfig.serverBuildPath)
: `${relative(
entrypointFsDirname,
dirname(remixConfig.serverBuildPath)
)}/build-${runtime}-${hash}.js`,
routes: routes.map(r => r.id),
export const build: BuildV2 = opts => {
const isLegacy = findConfig(opts.workPath, 'remix.config');
return isLegacy ? buildLegacy(opts) : buildVite(opts);
};
});
// If the project is *not* relying on split configurations, then set
// the `serverBuildPath` to the default Remix path, since the forked
// Remix compiler will not be used
if (!isHydrogen2 && serverBundles.length === 1) {
// `serverBuildTarget` and `serverBuildPath` are undefined with
// our remix config modifications, so use the default build path
serverBundles[0].serverBuildPath = 'build/index.js';
}
// If the project is relying on split configurations, then override
// the official `@remix-run/dev` package with the Vercel fork,
// which supports the `serverBundles` config
if (
serverBundles.length > 1 &&
!isHydrogen2 &&
remixRunDevPkg.name !== '@vercel/remix-run-dev' &&
!remixRunDevPkgVersion?.startsWith('https:')
) {
const remixDevForkVersion = resolveSemverMinMax(
REMIX_RUN_DEV_MIN_VERSION,
REMIX_RUN_DEV_MAX_VERSION,
remixVersion
);
// Remove `@remix-run/dev`, add `@vercel/remix-run-dev`
if (pkg.devDependencies['@remix-run/dev']) {
delete pkg.devDependencies['@remix-run/dev'];
pkg.devDependencies['@vercel/remix-run-dev'] = remixDevForkVersion;
} else {
delete pkg.dependencies['@remix-run/dev'];
pkg.dependencies['@vercel/remix-run-dev'] = remixDevForkVersion;
}
depsModified = true;
}
// `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']) {
// Dependency version resolution logic
// 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 known max
// 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
// the latest known published version of `@vercel/remix`.
const vercelRemixVersion = resolveSemverMinMax(
VERCEL_REMIX_MIN_VERSION,
REMIX_RUN_DEV_MAX_VERSION,
remixVersion
);
pkg.dependencies['@vercel/remix'] = vercelRemixVersion;
depsModified = true;
}
}
if (depsModified) {
await fs.writeFile(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
// Bypass `--frozen-lockfile` enforcement by removing
// env vars that are considered to be CI
const nonCiEnv = { ...spawnOpts.env };
delete nonCiEnv.CI;
delete nonCiEnv.VERCEL;
delete nonCiEnv.NOW_BUILDER;
// Purposefully not passing `meta` here to avoid
// the optimization that prevents `npm install`
// from running a second time
await runNpmInstall(
entrypointFsDirname,
[],
{
...spawnOpts,
env: nonCiEnv,
},
undefined,
nodeVersion
);
}
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;
let serverEntryPointAbs: string | undefined;
let originalServerEntryPoint: string | undefined;
const remixConfigPath = findConfig(entrypointFsDirname, 'remix.config');
const renamedRemixConfigPath = remixConfigPath
? `${remixConfigPath}.original${extname(remixConfigPath)}`
: undefined;
try {
// We need to patch the `remix.config.js` file to force some values necessary
// for a build that works on either Node.js or the Edge runtime
if (!isHydrogen2 && remixConfigPath && renamedRemixConfigPath) {
await fs.rename(remixConfigPath, renamedRemixConfigPath);
let patchedConfig: string;
// Figure out if the `remix.config` file is using ESM syntax
if (isESM(renamedRemixConfigPath)) {
patchedConfig = `import config from './${basename(
renamedRemixConfigPath
)}';
config.serverBuildTarget = undefined;
config.serverModuleFormat = '${pkg.type === 'module' ? 'esm' : 'cjs'}';
config.serverPlatform = 'node';
config.serverBuildPath = undefined;
config.serverBundles = ${JSON.stringify(serverBundles)};
export default config;`;
} else {
patchedConfig = `const config = require('./${basename(
renamedRemixConfigPath
)}');
config.serverBuildTarget = undefined;
config.serverModuleFormat = '${pkg.type === 'module' ? 'esm' : 'cjs'}';
config.serverPlatform = 'node';
config.serverBuildPath = undefined;
config.serverBundles = ${JSON.stringify(serverBundles)};
module.exports = config;`;
}
await fs.writeFile(remixConfigPath, patchedConfig);
remixConfigWrapped = true;
}
// For Hydrogen v2, patch the `server.ts` file to be Vercel-compatible
if (isHydrogen2) {
if (remixConfig.serverEntryPoint) {
serverEntryPointAbs = join(
entrypointFsDirname,
remixConfig.serverEntryPoint
);
originalServerEntryPoint = await fs.readFile(
serverEntryPointAbs,
'utf8'
);
const patchedServerEntryPoint = patchHydrogenServer(
project,
serverEntryPointAbs
);
if (patchedServerEntryPoint) {
debug(
`Patched Hydrogen server file: ${remixConfig.serverEntryPoint}`
);
await fs.writeFile(serverEntryPointAbs, patchedServerEntryPoint);
}
} else {
console.log('WARN: No "server" field found in Remix config');
}
}
// Make `remix build` output production mode
spawnOpts.env.NODE_ENV = 'production';
// Run "Build Command"
if (buildCommand) {
debug(`Executing build command "${buildCommand}"`);
await execCommand(buildCommand, {
...spawnOpts,
cwd: entrypointFsDirname,
});
} else {
if (hasScript('vercel-build', pkg)) {
debug(`Executing "yarn vercel-build"`);
await runPackageJsonScript(
entrypointFsDirname,
'vercel-build',
spawnOpts
);
} else if (hasScript('build', pkg)) {
debug(`Executing "yarn build"`);
await runPackageJsonScript(entrypointFsDirname, 'build', spawnOpts);
} else {
await execCommand('remix build', {
...spawnOpts,
cwd: entrypointFsDirname,
});
}
}
} finally {
const cleanupOps: Promise<void>[] = [];
// Clean up our patched `remix.config.js` to be polite
if (remixConfigWrapped && remixConfigPath && renamedRemixConfigPath) {
cleanupOps.push(
fs
.rename(renamedRemixConfigPath, remixConfigPath)
.then(() => debug(`Restored original "${remixConfigPath}" file`))
);
}
// Restore original server entrypoint if it was modified (for Hydrogen v2)
if (serverEntryPointAbs && originalServerEntryPoint) {
cleanupOps.push(
fs
.writeFile(serverEntryPointAbs, originalServerEntryPoint)
.then(() => debug(`Restored original "${serverEntryPointAbs}" file`))
);
}
// Restore original `package.json` file and lockfile
if (depsModified) {
cleanupOps.push(
fs
.writeFile(packageJsonPath, pkgRaw)
.then(() => debug(`Restored original "${packageJsonPath}" file`))
);
if (lockfilePath && lockfileRaw) {
cleanupOps.push(
fs
.writeFile(lockfilePath, lockfileRaw)
.then(() => debug(`Restored original "${lockfilePath}" file`))
);
}
}
await Promise.all(cleanupOps);
}
// This needs to happen before we run NFT to create the Node/Edge functions
await Promise.all([
ensureResolvable(
entrypointFsDirname,
repoRootPath,
'@remix-run/server-runtime'
),
!isHydrogen2
? ensureResolvable(entrypointFsDirname, repoRootPath, '@remix-run/node')
: null,
]);
const staticDir = join(entrypointFsDirname, 'public');
// Do a sanity check to ensure that the server bundles `serverBuildPath` was actually created.
// If it was not, then that usually means the Vercel forked Remix compiler was not used and
// thus only a singular server bundle was produced.
const serverBundlesRespected = existsSync(
join(entrypointFsDirname, serverBundles[0].serverBuildPath)
);
if (!serverBundlesRespected) {
console.warn(
'WARN: `serverBundles` configuration failed. Falling back to a singular server bundle.'
);
serverBundles = [
{
serverBuildPath: 'build/index.js',
routes: serverBundles.flatMap(b => b.routes),
},
];
}
const [staticFiles, buildAssets, ...functions] = await Promise.all([
glob('**', staticDir),
glob('**', remixConfig.assetsBuildDirectory),
...serverBundles.map(bundle => {
const firstRoute = remixConfig.routes[bundle.routes[0]];
const config = resolvedConfigsMap.get(firstRoute) ?? {
runtime: 'nodejs',
};
const serverBuildPath = join(entrypointFsDirname, bundle.serverBuildPath);
if (config.runtime === 'edge') {
return createRenderEdgeFunction(
entrypointFsDirname,
repoRootPath,
serverBuildPath,
serverEntryPoint,
remixVersion,
config
);
}
return createRenderNodeFunction(
nodeVersion,
entrypointFsDirname,
repoRootPath,
serverBuildPath,
serverEntryPoint,
remixVersion,
config
);
}),
]);
const transformedBuildAssets = rename(buildAssets, name => {
return posix.join('./', remixConfig.publicPath, name);
});
const output: BuildResultV2Typical['output'] = {
...staticFiles,
...transformedBuildAssets,
};
const routes: any[] = [
{
src: `^/${remixConfig.publicPath.replace(/^\/|\/$/g, '')}/(.*)$`,
headers: { 'cache-control': 'public, max-age=31536000, immutable' },
continue: true,
},
{
handle: 'filesystem',
},
];
for (const route of remixRoutes) {
// Layout routes don't get a function / route added
if (isLayoutRoute(route.id, remixRoutes)) continue;
const { path, rePath } = getPathFromRoute(route, remixConfig.routes);
// If the route is a pathless layout route (at the root level)
// and doesn't have any sub-routes, then a function should not be created.
if (!path) {
continue;
}
const funcIndex = serverBundles.findIndex(bundle => {
return bundle.routes.includes(route.id);
});
const func = functions[funcIndex];
if (!func) {
throw new Error(`Could not determine server bundle for "${route.id}"`);
}
output[path] = func;
// If this is a dynamic route then add a Vercel route
const re = getRegExpFromPath(rePath);
if (re) {
routes.push({
src: re.source,
dest: path,
});
}
}
// Add a 404 path for not found pages to be server-side rendered by Remix.
// Use an edge function bundle if one was generated, otherwise use Node.js.
if (!output['404']) {
const edgeFunctionIndex = Array.from(serverBundlesMap.values()).findIndex(
routes => {
const runtime = resolvedConfigsMap.get(routes[0])?.runtime;
return runtime === 'edge';
}
);
const func =
edgeFunctionIndex !== -1 ? functions[edgeFunctionIndex] : functions[0];
output['404'] = func;
}
routes.push({
src: '/(.*)',
dest: '/404',
});
return { routes, output, framework: { version: remixVersion } };
};
function hasScript(scriptName: string, pkg: PackageJson | null) {
const scripts = (pkg && pkg.scripts) || {};
return typeof scripts[scriptName] === 'string';
}
async function createRenderNodeFunction(
nodeVersion: NodeVersion,
entrypointDir: string,
rootDir: string,
serverBuildPath: string,
serverEntryPoint: string | undefined,
remixVersion: string,
config: ResolvedNodeRouteConfig
): Promise<NodejsLambda> {
const files: Files = {};
let handler = relative(rootDir, serverBuildPath);
let handlerPath = join(rootDir, handler);
if (!serverEntryPoint) {
const baseServerBuildPath = basename(serverBuildPath, '.js');
handler = join(dirname(handler), `server-${baseServerBuildPath}.mjs`);
handlerPath = join(rootDir, handler);
// Copy the `server-node.mjs` file into the "build" directory
const nodeServerSrc = await nodeServerSrcPromise;
await writeEntrypointFile(
handlerPath,
nodeServerSrc.replace(
'@remix-run/dev/server-build',
`./${baseServerBuildPath}.js`
),
rootDir
);
}
// Trace the handler with `@vercel/nft`
const trace = await nodeFileTrace([handlerPath], {
base: rootDir,
processCwd: entrypointDir,
});
for (const warning of trace.warnings) {
debug(`Warning from trace: ${warning.message}`);
}
for (const file of trace.fileList) {
files[file] = await FileFsRef.fromFsPath({ fsPath: join(rootDir, file) });
}
const fn = new NodejsLambda({
files,
handler,
runtime: nodeVersion.runtime,
shouldAddHelpers: false,
shouldAddSourcemapSupport: false,
operationType: 'SSR',
supportsResponseStreaming: true,
regions: config.regions,
memory: config.memory,
maxDuration: config.maxDuration,
framework: {
slug: 'remix',
version: remixVersion,
},
});
return fn;
}
async function createRenderEdgeFunction(
entrypointDir: string,
rootDir: string,
serverBuildPath: string,
serverEntryPoint: string | undefined,
remixVersion: string,
config: ResolvedEdgeRouteConfig
): Promise<EdgeFunction> {
const files: Files = {};
let handler = relative(rootDir, serverBuildPath);
let handlerPath = join(rootDir, handler);
if (!serverEntryPoint) {
const baseServerBuildPath = basename(serverBuildPath, '.js');
handler = join(dirname(handler), `server-${baseServerBuildPath}.mjs`);
handlerPath = join(rootDir, handler);
// Copy the `server-edge.mjs` file into the "build" directory
const edgeServerSrc = await edgeServerSrcPromise;
await writeEntrypointFile(
handlerPath,
edgeServerSrc.replace(
'@remix-run/dev/server-build',
`./${baseServerBuildPath}.js`
),
rootDir
);
}
let remixRunVercelPkgJson: string | undefined;
// Trace the handler with `@vercel/nft`
const trace = await nodeFileTrace([handlerPath], {
base: rootDir,
processCwd: entrypointDir,
conditions: ['edge-light', 'browser', 'module', 'import', 'require'],
async readFile(fsPath) {
let source: Buffer | string;
try {
source = await fs.readFile(fsPath);
} catch (err: any) {
if (err.code === 'ENOENT' || err.code === 'EISDIR') {
return null;
}
throw err;
}
if (basename(fsPath) === 'package.json') {
// For Edge Functions, patch "main" field to prefer "browser" or "module"
const pkgJson = JSON.parse(source.toString());
// When `@remix-run/vercel` is detected, we need to modify the `package.json`
// to include the "browser" field so that the proper Edge entrypoint file
// is used. This is a temporary stop gap until this PR is merged:
// https://github.com/remix-run/remix/pull/5537
if (pkgJson.name === '@remix-run/vercel') {
pkgJson.browser = 'dist/edge.js';
pkgJson.dependencies['@remix-run/server-runtime'] =
pkgJson.dependencies['@remix-run/node'];
if (!remixRunVercelPkgJson) {
remixRunVercelPkgJson = JSON.stringify(pkgJson, null, 2) + '\n';
// Copy in the edge entrypoint so that NFT can properly resolve it
const vercelEdgeEntrypointPath = join(
DEFAULTS_PATH,
'vercel-edge-entrypoint.js'
);
const vercelEdgeEntrypointDest = join(
dirname(fsPath),
'dist/edge.js'
);
await fs.copyFile(
vercelEdgeEntrypointPath,
vercelEdgeEntrypointDest
);
}
}
for (const prop of ['browser', 'module']) {
const val = pkgJson[prop];
if (typeof val === 'string') {
pkgJson.main = val;
// Return the modified `package.json` to nft
source = JSON.stringify(pkgJson);
break;
}
}
}
return source;
},
});
for (const warning of trace.warnings) {
debug(`Warning from trace: ${warning.message}`);
}
for (const file of trace.fileList) {
if (
remixRunVercelPkgJson &&
file.endsWith(`@remix-run${sep}vercel${sep}package.json`)
) {
// Use the modified `@remix-run/vercel` package.json which contains "browser" field
files[file] = new FileBlob({ data: remixRunVercelPkgJson });
} else {
files[file] = await FileFsRef.fromFsPath({ fsPath: join(rootDir, file) });
}
}
const fn = new EdgeFunction({
files,
deploymentTarget: 'v8-worker',
entrypoint: handler,
regions: config.regions,
framework: {
slug: 'remix',
version: remixVersion,
},
});
return fn;
}
async function writeEntrypointFile(
path: string,
data: string,
rootDir: string
) {
try {
await fs.writeFile(path, data);
} catch (err: any) {
if (err.code === 'ENOENT') {
throw new Error(
`The "${relative(
rootDir,
dirname(path)
)}" directory does not exist. Please contact support at https://vercel.com/help.`
);
}
throw err;
}
}

View File

@@ -2,7 +2,7 @@ import semver from 'semver';
import { existsSync, promises as fs } from 'fs';
import { basename, dirname, join, relative, resolve, sep } from 'path';
import { pathToRegexp, Key } from 'path-to-regexp';
import { debug } from '@vercel/build-utils';
import { debug, type PackageJson } from '@vercel/build-utils';
import { walkParentDirs } from '@vercel/build-utils';
import { createRequire } from 'module';
import type {
@@ -58,8 +58,12 @@ export function findEntry(dir: string, basename: string): string | undefined {
const configExts = ['.js', '.cjs', '.mjs'];
export function findConfig(dir: string, basename: string): string | undefined {
for (const ext of configExts) {
export function findConfig(
dir: string,
basename: string,
exts = configExts
): string | undefined {
for (const ext of exts) {
const name = basename + ext;
const file = join(dir, name);
if (existsSync(file)) return file;
@@ -369,3 +373,8 @@ export function isESM(path: string): boolean {
}
return isESM;
}
export function hasScript(scriptName: string, pkg?: PackageJson) {
const scripts = pkg?.scripts || {};
return typeof scripts[scriptName] === 'string';
}

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Some files were not shown because too many files have changed in this diff Show More