mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-10 04:22:12 +00:00
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 ```
478 lines
13 KiB
TypeScript
478 lines
13 KiB
TypeScript
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;
|
|
}
|