[remix] Support "regions", "memory" and "maxDuration" in static config (#9442)

Apply the `regions` configuration (for both Edge and Node) and `memory`/`maxDuration` (only for Node) in a page's static config export, i.e.

```js
export const config = { runtime: 'edge', regions: ['iad1'] }

// or for Node
export const config = { runtime: 'nodejs', regions: ['iad1'], maxDuration: 5, memory: 3008 }
```

Similar to `runtime`, these config values can be inherited from a parent layout route to apply to all sub-routes. Routes with common config settings get placed into a common server bundle, meaning that there may now be more than 2 functions created (previously was one Edge, one Node), allowing for more granularity between the server build bundles.
This commit is contained in:
Nathan Rajlich
2023-03-01 13:45:10 -08:00
committed by GitHub
parent 61de63d285
commit 0bbb06daa7
8 changed files with 289 additions and 77 deletions

View File

@@ -19,7 +19,7 @@ import {
scanParentDirs,
walkParentDirs,
} from '@vercel/build-utils';
import { getConfig, BaseFunctionConfig } from '@vercel/static-config';
import { getConfig } from '@vercel/static-config';
import { nodeFileTrace } from '@vercel/nft';
import { readConfig } from '@remix-run/dev/dist/config';
import type {
@@ -29,14 +29,19 @@ import type {
PackageJson,
BuildResultV2Typical,
} from '@vercel/build-utils';
import type { BaseFunctionConfig } from '@vercel/static-config';
import type { RemixConfig } from '@remix-run/dev/dist/config';
import type { ConfigRoute } from '@remix-run/dev/dist/config/routes';
import {
calculateRouteConfigHash,
findConfig,
getPathFromRoute,
getRegExpFromPath,
getRouteIterator,
getResolvedRouteConfig,
isLayoutRoute,
ResolvedRouteConfig,
ResolvedNodeRouteConfig,
ResolvedEdgeRouteConfig,
} from './utils';
const _require: typeof require = eval('require');
@@ -45,6 +50,15 @@ const REMIX_RUN_DEV_PATH = dirname(
_require.resolve('@remix-run/dev/package.json')
);
const edgeServerSrcPromise = fs.readFile(
join(__dirname, '../server-edge.mjs'),
'utf-8'
);
const nodeServerSrcPromise = fs.readFile(
join(__dirname, '../server-node.mjs'),
'utf-8'
);
export const build: BuildV2 = async ({
entrypoint,
files,
@@ -133,49 +147,53 @@ export const build: BuildV2 = async ({
// Read the `export const config` (if any) for each route
const project = new Project();
const staticConfigsMap = new Map<ConfigRoute, BaseFunctionConfig>();
const staticConfigsMap = new Map<ConfigRoute, BaseFunctionConfig | null>();
for (const route of remixRoutes) {
const routePath = join(remixConfig.appDirectory, route.file);
const staticConfig = getConfig(project, routePath);
if (staticConfig) {
staticConfigsMap.set(route, staticConfig);
}
staticConfigsMap.set(route, staticConfig);
}
// Figure out which pages should be edge functions
const edgeRoutes = new Set<ConfigRoute>();
const nodeRoutes = new Set<ConfigRoute>();
const resolvedConfigsMap = new Map<ConfigRoute, ResolvedRouteConfig>();
for (const route of remixRoutes) {
const config = getResolvedRouteConfig(
route,
remixConfig.routes,
staticConfigsMap
);
resolvedConfigsMap.set(route, config);
}
// Figure out which routes belong to which server bundles
// based on having common static config properties
const serverBundlesMap = new Map<string, ConfigRoute[]>();
for (const route of remixRoutes) {
if (isLayoutRoute(route.id, remixRoutes)) continue;
// Support runtime inheritance when defined in a parent route,
// by iterating through the route's parent hierarchy until a
// config containing "runtime" is found.
let isEdge = false;
for (const currentRoute of getRouteIterator(route, remixConfig.routes)) {
const staticConfig = staticConfigsMap.get(currentRoute);
if (staticConfig?.runtime) {
isEdge = isEdgeRuntime(staticConfig.runtime);
break;
}
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);
}
if (isEdge) {
edgeRoutes.add(route);
} else {
nodeRoutes.add(route);
}
routesForHash.push(route);
}
const serverBundles = [
{
serverBuildPath: 'build/build-edge.js',
routes: Array.from(edgeRoutes).map(r => r.id),
},
{
serverBuildPath: 'build/build-node.js',
routes: Array.from(nodeRoutes).map(r => r.id),
},
];
const serverBundles = Array.from(serverBundlesMap.entries()).map(
([hash, routes]) => {
const runtime = resolvedConfigsMap.get(routes[0])?.runtime ?? 'nodejs';
return {
serverBuildPath: `build/build-${runtime}-${hash}.js`,
routes: routes.map(r => r.id),
};
}
);
// 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
@@ -272,25 +290,35 @@ module.exports = config;`;
ensureResolvable(entrypointFsDirname, repoRootPath, '@remix-run/node'),
]);
const [staticFiles, nodeFunction, edgeFunction] = await Promise.all([
const [staticFiles, ...functions] = await Promise.all([
glob('**', join(entrypointFsDirname, 'public')),
createRenderNodeFunction(
nodeVersion,
entrypointFsDirname,
repoRootPath,
join(entrypointFsDirname, 'build/build-node.js'),
remixConfig.serverEntryPoint,
remixVersion
),
edgeRoutes.size > 0
? createRenderEdgeFunction(
...serverBundles.map(bundle => {
const firstRoute = remixConfig.routes[bundle.routes[0]];
const config = resolvedConfigsMap.get(firstRoute) ?? {
runtime: 'nodejs',
};
if (config.runtime === 'edge') {
return createRenderEdgeFunction(
entrypointFsDirname,
repoRootPath,
join(entrypointFsDirname, 'build/build-edge.js'),
join(entrypointFsDirname, bundle.serverBuildPath),
remixConfig.serverEntryPoint,
remixVersion
)
: undefined,
remixVersion,
config
);
}
return createRenderNodeFunction(
nodeVersion,
entrypointFsDirname,
repoRootPath,
join(entrypointFsDirname, bundle.serverBuildPath),
remixConfig.serverEntryPoint,
remixVersion,
config
);
}),
]);
const output: BuildResultV2Typical['output'] = staticFiles;
@@ -317,18 +345,26 @@ module.exports = config;`;
continue;
}
const fn =
edgeRoutes.has(route) && edgeFunction
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 instanceof EdgeFunction
? // `EdgeFunction` currently requires the "name" property to be set.
// Ideally this property will be removed, at which point we can
// return the same `edgeFunction` instance instead of creating a
// new one for each page.
new EdgeFunction({
...edgeFunction,
...func,
name: path,
})
: nodeFunction;
output[path] = fn;
: func;
// If this is a dynamic route then add a Vercel route
const re = getRegExpFromPath(path);
@@ -341,11 +377,20 @@ module.exports = config;`;
}
// Add a 404 path for not found pages to be server-side rendered by Remix.
// Use the edge function if one was generated, otherwise use Node.js.
// Use an edge function bundle if one was generated, otherwise use Node.js.
if (!output['404']) {
output['404'] = edgeFunction
? new EdgeFunction({ ...edgeFunction, name: '404' })
: nodeFunction;
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 instanceof EdgeFunction
? new EdgeFunction({ ...func, name: '404' })
: func;
}
routes.push({
src: '/(.*)',
@@ -366,19 +411,27 @@ async function createRenderNodeFunction(
rootDir: string,
serverBuildPath: string,
serverEntryPoint: string | undefined,
remixVersion: string
remixVersion: string,
config: ResolvedNodeRouteConfig
): Promise<NodejsLambda> {
const files: Files = {};
let handler = relative(rootDir, serverBuildPath);
let handlerPath = join(rootDir, handler);
if (!serverEntryPoint) {
handler = join(dirname(handler), 'server-node.mjs');
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 sourceHandlerPath = join(__dirname, '../server-node.mjs');
await fs.copyFile(sourceHandlerPath, handlerPath);
const nodeServerSrc = await nodeServerSrcPromise;
await fs.writeFile(
handlerPath,
nodeServerSrc.replace(
'@remix-run/dev/server-build',
`./${baseServerBuildPath}.js`
)
);
}
// Trace the handler with `@vercel/nft`
@@ -403,6 +456,9 @@ async function createRenderNodeFunction(
shouldAddSourcemapSupport: false,
operationType: 'SSR',
experimentalResponseStreaming: true,
regions: config.regions,
memory: config.memory,
maxDuration: config.maxDuration,
framework: {
slug: 'remix',
version: remixVersion,
@@ -417,19 +473,27 @@ async function createRenderEdgeFunction(
rootDir: string,
serverBuildPath: string,
serverEntryPoint: string | undefined,
remixVersion: string
remixVersion: string,
config: ResolvedEdgeRouteConfig
): Promise<EdgeFunction> {
const files: Files = {};
let handler = relative(rootDir, serverBuildPath);
let handlerPath = join(rootDir, handler);
if (!serverEntryPoint) {
handler = join(dirname(handler), 'server-edge.mjs');
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 sourceHandlerPath = join(__dirname, '../server-edge.mjs');
await fs.copyFile(sourceHandlerPath, handlerPath);
const edgeServerSrc = await edgeServerSrcPromise;
await fs.writeFile(
handlerPath,
edgeServerSrc.replace(
'@remix-run/dev/server-build',
`./${baseServerBuildPath}.js`
)
);
}
let remixRunVercelPkgJson: string | undefined;
@@ -517,6 +581,7 @@ async function createRenderEdgeFunction(
deploymentTarget: 'v8-worker',
name: 'render',
entrypoint: handler,
regions: config.regions,
framework: {
slug: 'remix',
version: remixVersion,
@@ -614,10 +679,6 @@ async function ensureSymlink(
);
}
function isEdgeRuntime(runtime: string): boolean {
return runtime === 'edge' || runtime === 'experimental-edge';
}
async function chdirAndReadConfig(dir: string) {
const originalCwd = process.cwd();
let remixConfig: RemixConfig;

View File

@@ -5,6 +5,22 @@ import type {
ConfigRoute,
RouteManifest,
} from '@remix-run/dev/dist/config/routes';
import type { BaseFunctionConfig } from '@vercel/static-config';
export interface ResolvedNodeRouteConfig {
runtime: 'nodejs';
regions?: string[];
maxDuration?: number;
memory?: number;
}
export interface ResolvedEdgeRouteConfig {
runtime: 'edge';
regions?: BaseFunctionConfig['regions'];
}
export type ResolvedRouteConfig =
| ResolvedNodeRouteConfig
| ResolvedEdgeRouteConfig;
const configExts = ['.js', '.cjs', '.mjs'];
@@ -18,6 +34,60 @@ export function findConfig(dir: string, basename: string): string | undefined {
return undefined;
}
function isEdgeRuntime(runtime: string): boolean {
return runtime === 'edge' || runtime === 'experimental-edge';
}
export function getResolvedRouteConfig(
route: ConfigRoute,
routes: RouteManifest,
configs: Map<ConfigRoute, BaseFunctionConfig | null>
): ResolvedRouteConfig {
let runtime: ResolvedRouteConfig['runtime'] | undefined;
let regions: ResolvedRouteConfig['regions'];
let maxDuration: ResolvedNodeRouteConfig['maxDuration'];
let memory: ResolvedNodeRouteConfig['memory'];
for (const currentRoute of getRouteIterator(route, routes)) {
const staticConfig = configs.get(currentRoute);
if (staticConfig) {
if (typeof runtime === 'undefined' && staticConfig.runtime) {
runtime = isEdgeRuntime(staticConfig.runtime) ? 'edge' : 'nodejs';
}
if (typeof regions === 'undefined') {
regions = staticConfig.regions;
}
if (typeof maxDuration === 'undefined') {
maxDuration = staticConfig.maxDuration;
}
if (typeof memory === 'undefined') {
memory = staticConfig.memory;
}
}
}
if (Array.isArray(regions)) {
regions = Array.from(new Set(regions)).sort();
}
if (runtime === 'edge') {
return { runtime, regions };
}
if (regions && !Array.isArray(regions)) {
throw new Error(
`"regions" for route "${route.id}" must be an array of strings`
);
}
return { runtime: 'nodejs', regions, maxDuration, memory };
}
export function calculateRouteConfigHash(config: ResolvedRouteConfig): string {
const str = JSON.stringify(config);
return Buffer.from(str).toString('base64url');
}
export function isLayoutRoute(
routeId: string,
routes: Pick<ConfigRoute, 'id' | 'parentId'>[]