mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-10 04:22:12 +00:00
[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:
@@ -1,3 +1,3 @@
|
||||
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);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
installGlobals();
|
||||
|
||||
import build from './build-node.js';
|
||||
import build from '@remix-run/dev/server-build';
|
||||
|
||||
const handleRequest = createRemixRequestHandler(build, process.env.NODE_ENV);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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
|
||||
const edgeRoutes = new Set<ConfigRoute>();
|
||||
const nodeRoutes = new Set<ConfigRoute>();
|
||||
// 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 = 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
|
||||
// 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(
|
||||
...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,
|
||||
entrypointFsDirname,
|
||||
repoRootPath,
|
||||
join(entrypointFsDirname, 'build/build-node.js'),
|
||||
join(entrypointFsDirname, bundle.serverBuildPath),
|
||||
remixConfig.serverEntryPoint,
|
||||
remixVersion
|
||||
),
|
||||
edgeRoutes.size > 0
|
||||
? createRenderEdgeFunction(
|
||||
entrypointFsDirname,
|
||||
repoRootPath,
|
||||
join(entrypointFsDirname, 'build/build-edge.js'),
|
||||
remixConfig.serverEntryPoint,
|
||||
remixVersion
|
||||
)
|
||||
: undefined,
|
||||
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;
|
||||
|
||||
@@ -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'>[]
|
||||
|
||||
@@ -8,10 +8,13 @@ import { json } from '@remix-run/node';
|
||||
|
||||
export async function loader() {
|
||||
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);
|
||||
resolve(stdout);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
return json({ hi });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { loader } from '~/b.server';
|
||||
import { useLoaderData } from '@remix-run/react';
|
||||
|
||||
export const config = { regions: ['sfo1'] };
|
||||
|
||||
export { loader };
|
||||
|
||||
export default function B() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"probes": [
|
||||
{ "path": "/", "mustContain": "Welcome to Remix" },
|
||||
{ "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/another", "mustContain": "Nested another page" },
|
||||
{ "path": "/nested/index", "status": 404, "mustContain": "Not Found" },
|
||||
|
||||
76
packages/remix/test/unit.get-resolved-route-config.test.ts
vendored
Normal file
76
packages/remix/test/unit.get-resolved-route-config.test.ts
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user