mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-06 04:22:01 +00:00
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:
5
.changeset/metal-tips-decide.md
Normal file
5
.changeset/metal-tips-decide.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'@vercel/remix-builder': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Remix Vite plugin support
|
||||||
6
.changeset/sour-cameras-eat.md
Normal file
6
.changeset/sour-cameras-eat.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
'@vercel/frameworks': major
|
||||||
|
'@vercel/fs-detectors': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Make "remix" framework preset supersede "vite"
|
||||||
@@ -38,7 +38,7 @@ packages/static-build/test/cache-fixtures
|
|||||||
packages/redwood/test/fixtures
|
packages/redwood/test/fixtures
|
||||||
|
|
||||||
# remix
|
# remix
|
||||||
packages/remix/test/fixtures
|
packages/remix/test/fixtures-*
|
||||||
|
|
||||||
# gatsby-plugin-vercel-analytics
|
# gatsby-plugin-vercel-analytics
|
||||||
packages/gatsby-plugin-vercel-analytics
|
packages/gatsby-plugin-vercel-analytics
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ turbo-cache-key.json
|
|||||||
packages/*/dist
|
packages/*/dist
|
||||||
packages/*/node_modules
|
packages/*/node_modules
|
||||||
packages/**/test/fixtures
|
packages/**/test/fixtures
|
||||||
|
packages/**/test/fixtures-*
|
||||||
packages/**/test/dev/fixtures
|
packages/**/test/dev/fixtures
|
||||||
packages/**/test/build-fixtures
|
packages/**/test/build-fixtures
|
||||||
packages/**/test/cache-fixtures
|
packages/**/test/cache-fixtures
|
||||||
|
|||||||
@@ -202,11 +202,14 @@ export const frameworks = [
|
|||||||
description: 'A new Remix app — the result of running `npx create-remix`.',
|
description: 'A new Remix app — the result of running `npx create-remix`.',
|
||||||
website: 'https://remix.run',
|
website: 'https://remix.run',
|
||||||
sort: 6,
|
sort: 6,
|
||||||
supersedes: 'hydrogen',
|
supersedes: ['hydrogen', 'vite'],
|
||||||
useRuntime: { src: 'package.json', use: '@vercel/remix-builder' },
|
useRuntime: { src: 'package.json', use: '@vercel/remix-builder' },
|
||||||
ignoreRuntimes: ['@vercel/node'],
|
ignoreRuntimes: ['@vercel/node'],
|
||||||
detectors: {
|
detectors: {
|
||||||
some: [
|
some: [
|
||||||
|
{
|
||||||
|
matchPackage: '@remix-run/dev',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'remix.config.js',
|
path: 'remix.config.js',
|
||||||
},
|
},
|
||||||
@@ -1734,7 +1737,7 @@ export const frameworks = [
|
|||||||
tagline: 'React framework for headless commerce',
|
tagline: 'React framework for headless commerce',
|
||||||
description: 'React framework for headless commerce',
|
description: 'React framework for headless commerce',
|
||||||
website: 'https://hydrogen.shopify.dev',
|
website: 'https://hydrogen.shopify.dev',
|
||||||
supersedes: 'vite',
|
supersedes: ['vite'],
|
||||||
useRuntime: { src: 'package.json', use: '@vercel/hydrogen' },
|
useRuntime: { src: 'package.json', use: '@vercel/hydrogen' },
|
||||||
envPrefix: 'PUBLIC_',
|
envPrefix: 'PUBLIC_',
|
||||||
detectors: {
|
detectors: {
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export interface Framework {
|
|||||||
*/
|
*/
|
||||||
defaultVersion?: string;
|
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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ const Schema = {
|
|||||||
dependency: { type: 'string' },
|
dependency: { type: 'string' },
|
||||||
cachePattern: { type: 'string' },
|
cachePattern: { type: 'string' },
|
||||||
defaultVersion: { type: 'string' },
|
defaultVersion: { type: 'string' },
|
||||||
supersedes: { type: 'string' },
|
supersedes: { type: 'array', items: { type: 'string' } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -143,7 +143,9 @@ function removeSupersededFramework(
|
|||||||
const framework = matches[index];
|
const framework = matches[index];
|
||||||
if (framework) {
|
if (framework) {
|
||||||
if (framework.supersedes) {
|
if (framework.supersedes) {
|
||||||
removeSupersededFramework(matches, framework.supersedes);
|
for (const slug of framework.supersedes) {
|
||||||
|
removeSupersededFramework(matches, slug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
matches.splice(index, 1);
|
matches.splice(index, 1);
|
||||||
}
|
}
|
||||||
@@ -154,7 +156,9 @@ export function removeSupersededFrameworks(
|
|||||||
) {
|
) {
|
||||||
for (const match of matches.slice()) {
|
for (const match of matches.slice()) {
|
||||||
if (match?.supersedes) {
|
if (match?.supersedes) {
|
||||||
removeSupersededFramework(matches, match.supersedes);
|
for (const slug of match.supersedes) {
|
||||||
|
removeSupersededFramework(matches, slug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,12 +166,12 @@ describe('removeSupersededFrameworks()', () => {
|
|||||||
const matches = [
|
const matches = [
|
||||||
{ slug: 'storybook' },
|
{ slug: 'storybook' },
|
||||||
{ slug: 'vite' },
|
{ slug: 'vite' },
|
||||||
{ slug: 'hydrogen', supersedes: 'vite' },
|
{ slug: 'hydrogen', supersedes: ['vite'] },
|
||||||
];
|
];
|
||||||
removeSupersededFrameworks(matches);
|
removeSupersededFrameworks(matches);
|
||||||
expect(matches).toEqual([
|
expect(matches).toEqual([
|
||||||
{ slug: 'storybook' },
|
{ slug: 'storybook' },
|
||||||
{ slug: 'hydrogen', supersedes: 'vite' },
|
{ slug: 'hydrogen', supersedes: ['vite'] },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,13 +179,13 @@ describe('removeSupersededFrameworks()', () => {
|
|||||||
const matches = [
|
const matches = [
|
||||||
{ slug: 'storybook' },
|
{ slug: 'storybook' },
|
||||||
{ slug: 'vite' },
|
{ slug: 'vite' },
|
||||||
{ slug: 'hydrogen', supersedes: 'vite' },
|
{ slug: 'hydrogen', supersedes: ['vite'] },
|
||||||
{ slug: 'remix', supersedes: 'hydrogen' },
|
{ slug: 'remix', supersedes: ['hydrogen'] },
|
||||||
];
|
];
|
||||||
removeSupersededFrameworks(matches);
|
removeSupersededFrameworks(matches);
|
||||||
expect(matches).toEqual([
|
expect(matches).toEqual([
|
||||||
{ slug: 'storybook' },
|
{ slug: 'storybook' },
|
||||||
{ slug: 'remix', supersedes: 'hydrogen' },
|
{ slug: 'remix', supersedes: ['hydrogen'] },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -442,6 +442,20 @@ describe('detectFramework()', () => {
|
|||||||
|
|
||||||
expect(await detectFramework({ fs, frameworkList })).toBe('storybook');
|
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()', () => {
|
describe('detectFrameworks()', () => {
|
||||||
@@ -497,6 +511,23 @@ describe('detectFrameworks()', () => {
|
|||||||
expect(slugs).toEqual(['nextjs', 'storybook']);
|
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 () => {
|
it('Should detect "hydrogen" template as `hydrogen`', async () => {
|
||||||
const fs = new LocalFileSystemDetector(join(EXAMPLES_DIR, 'hydrogen'));
|
const fs = new LocalFileSystemDetector(join(EXAMPLES_DIR, 'hydrogen'));
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"homepage": "https://vercel.com/docs",
|
"homepage": "https://vercel.com/docs",
|
||||||
|
"sideEffects": false,
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/vercel/vercel.git",
|
"url": "https://github.com/vercel/vercel.git",
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
"build": "node ../../utils/build-builder.mjs",
|
"build": "node ../../utils/build-builder.mjs",
|
||||||
"test": "jest --reporters=default --reporters=jest-junit --env node --verbose --bail --runInBand",
|
"test": "jest --reporters=default --reporters=jest-junit --env node --verbose --bail --runInBand",
|
||||||
"test-unit": "pnpm test test/unit.*test.*",
|
"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"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"defaults"
|
"defaults"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vercel/error-utils": "2.0.2",
|
||||||
"@vercel/nft": "0.26.4",
|
"@vercel/nft": "0.26.4",
|
||||||
"@vercel/static-config": "3.0.0",
|
"@vercel/static-config": "3.0.0",
|
||||||
"ts-morph": "12.0.0"
|
"ts-morph": "12.0.0"
|
||||||
|
|||||||
814
packages/remix/src/build-legacy.ts
Normal file
814
packages/remix/src/build-legacy.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
477
packages/remix/src/build-vite.ts
Normal file
477
packages/remix/src/build-vite.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,814 +1,9 @@
|
|||||||
import { Project } from 'ts-morph';
|
import { build as buildVite } from './build-vite';
|
||||||
import { readFileSync, promises as fs, existsSync } from 'fs';
|
import { build as buildLegacy } from './build-legacy';
|
||||||
import { basename, dirname, extname, join, posix, relative, sep } from 'path';
|
import { findConfig } from './utils';
|
||||||
import {
|
import type { BuildV2 } from '@vercel/build-utils';
|
||||||
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 {
|
export const build: BuildV2 = opts => {
|
||||||
serverBuildPath: string;
|
const isLegacy = findConfig(opts.workPath, 'remix.config');
|
||||||
routes: string[];
|
return isLegacy ? buildLegacy(opts) : buildVite(opts);
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import semver from 'semver';
|
|||||||
import { existsSync, promises as fs } from 'fs';
|
import { existsSync, promises as fs } from 'fs';
|
||||||
import { basename, dirname, join, relative, resolve, sep } from 'path';
|
import { basename, dirname, join, relative, resolve, sep } from 'path';
|
||||||
import { pathToRegexp, Key } from 'path-to-regexp';
|
import { pathToRegexp, Key } from 'path-to-regexp';
|
||||||
import { debug } from '@vercel/build-utils';
|
import { debug, type PackageJson } from '@vercel/build-utils';
|
||||||
import { walkParentDirs } from '@vercel/build-utils';
|
import { walkParentDirs } from '@vercel/build-utils';
|
||||||
import { createRequire } from 'module';
|
import { createRequire } from 'module';
|
||||||
import type {
|
import type {
|
||||||
@@ -58,8 +58,12 @@ export function findEntry(dir: string, basename: string): string | undefined {
|
|||||||
|
|
||||||
const configExts = ['.js', '.cjs', '.mjs'];
|
const configExts = ['.js', '.cjs', '.mjs'];
|
||||||
|
|
||||||
export function findConfig(dir: string, basename: string): string | undefined {
|
export function findConfig(
|
||||||
for (const ext of configExts) {
|
dir: string,
|
||||||
|
basename: string,
|
||||||
|
exts = configExts
|
||||||
|
): string | undefined {
|
||||||
|
for (const ext of exts) {
|
||||||
const name = basename + ext;
|
const name = basename + ext;
|
||||||
const file = join(dir, name);
|
const file = join(dir, name);
|
||||||
if (existsSync(file)) return file;
|
if (existsSync(file)) return file;
|
||||||
@@ -369,3 +373,8 @@ export function isESM(path: string): boolean {
|
|||||||
}
|
}
|
||||||
return isESM;
|
return isESM;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasScript(scriptName: string, pkg?: PackageJson) {
|
||||||
|
const scripts = pkg?.scripts || {};
|
||||||
|
return typeof scripts[scriptName] === 'string';
|
||||||
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
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
Reference in New Issue
Block a user