[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

@@ -1,3 +1,3 @@
import { createRequestHandler } from '@remix-run/server-runtime'; import { createRequestHandler } from '@remix-run/server-runtime';
import build from './build-edge.js'; import build from '@remix-run/dev/server-build';
export default createRequestHandler(build); export default createRequestHandler(build);

View File

@@ -9,7 +9,7 @@ import {
installGlobals(); installGlobals();
import build from './build-node.js'; import build from '@remix-run/dev/server-build';
const handleRequest = createRemixRequestHandler(build, process.env.NODE_ENV); const handleRequest = createRemixRequestHandler(build, process.env.NODE_ENV);

View File

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

View File

@@ -5,6 +5,22 @@ import type {
ConfigRoute, ConfigRoute,
RouteManifest, RouteManifest,
} from '@remix-run/dev/dist/config/routes'; } 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']; const configExts = ['.js', '.cjs', '.mjs'];
@@ -18,6 +34,60 @@ export function findConfig(dir: string, basename: string): string | undefined {
return 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( export function isLayoutRoute(
routeId: string, routeId: string,
routes: Pick<ConfigRoute, 'id' | 'parentId'>[] routes: Pick<ConfigRoute, 'id' | 'parentId'>[]

View File

@@ -8,10 +8,13 @@ import { json } from '@remix-run/node';
export async function loader() { export async function loader() {
const hi = await new Promise<string>((resolve, reject) => { const hi = await new Promise<string>((resolve, reject) => {
exec('echo hi from the B page', (err, stdout) => { exec(
`echo hi from the B page running in ${process.env.VERCEL_REGION}`,
(err, stdout) => {
if (err) return reject(err); if (err) return reject(err);
resolve(stdout); resolve(stdout);
}); }
);
}); });
return json({ hi }); return json({ hi });
} }

View File

@@ -1,6 +1,8 @@
import { loader } from '~/b.server'; import { loader } from '~/b.server';
import { useLoaderData } from '@remix-run/react'; import { useLoaderData } from '@remix-run/react';
export const config = { regions: ['sfo1'] };
export { loader }; export { loader };
export default function B() { export default function B() {

View File

@@ -2,7 +2,7 @@
"probes": [ "probes": [
{ "path": "/", "mustContain": "Welcome to Remix" }, { "path": "/", "mustContain": "Welcome to Remix" },
{ "path": "/edge", "mustContain": "Welcome to Remix@Edge" }, { "path": "/edge", "mustContain": "Welcome to Remix@Edge" },
{ "path": "/b", "mustContain": "hi from the B page" }, { "path": "/b", "mustContain": "hi from the B page running in sfo1" },
{ "path": "/nested", "mustContain": "Nested index page" }, { "path": "/nested", "mustContain": "Nested index page" },
{ "path": "/nested/another", "mustContain": "Nested another page" }, { "path": "/nested/another", "mustContain": "Nested another page" },
{ "path": "/nested/index", "status": 404, "mustContain": "Not Found" }, { "path": "/nested/index", "status": 404, "mustContain": "Not Found" },

View File

@@ -0,0 +1,76 @@
import { getResolvedRouteConfig } from '../src/utils';
import type {
ConfigRoute,
RouteManifest,
} from '@remix-run/dev/dist/config/routes';
import type { BaseFunctionConfig } from '@vercel/static-config';
describe('getResolvedRouteConfig()', () => {
const staticConfigsMap = new Map<ConfigRoute, BaseFunctionConfig | null>([
[{ id: 'root', file: 'root.tsx' }, null],
[
{ id: 'routes/edge', file: 'routes/edge.tsx', parentId: 'root' },
{ runtime: 'edge' },
],
[
{
id: 'routes/edge/sfo1',
file: 'routes/edge/sfo1.tsx',
parentId: 'routes/edge',
},
{ regions: ['sfo1'] },
],
[
{
id: 'routes/edge/iad1',
file: 'routes/edge/iad1.tsx',
parentId: 'routes/edge',
},
{ regions: ['iad1'] },
],
[
{ id: 'routes/node', file: 'routes/node.tsx' },
{ runtime: 'nodejs', regions: ['sfo1'] },
],
[
{
id: 'routes/node/mem',
file: 'routes/node/mem.tsx',
parentId: 'routes/node',
},
{ maxDuration: 5, memory: 3008 },
],
]);
const routes: RouteManifest = {};
for (const route of staticConfigsMap.keys()) {
routes[route.id] = route;
}
it.each([
{ id: 'root', expected: { runtime: 'nodejs' } },
{ id: 'routes/edge', expected: { runtime: 'edge' } },
{
id: 'routes/edge/sfo1',
expected: { runtime: 'edge', regions: ['sfo1'] },
},
{
id: 'routes/edge/iad1',
expected: { runtime: 'edge', regions: ['iad1'] },
},
{ id: 'routes/node', expected: { runtime: 'nodejs', regions: ['sfo1'] } },
{
id: 'routes/node/mem',
expected: {
runtime: 'nodejs',
regions: ['sfo1'],
maxDuration: 5,
memory: 3008,
},
},
])('should resolve config for "$id" route', ({ id, expected }) => {
const route = routes[id];
const config = getResolvedRouteConfig(route, routes, staticConfigsMap);
expect(config).toMatchObject(expected);
});
});