diff --git a/packages/remix/server-edge.mjs b/packages/remix/server-edge.mjs index 68e78d8cc..021196282 100644 --- a/packages/remix/server-edge.mjs +++ b/packages/remix/server-edge.mjs @@ -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); diff --git a/packages/remix/server-node.mjs b/packages/remix/server-node.mjs index ed7e5466b..e00000a69 100644 --- a/packages/remix/server-node.mjs +++ b/packages/remix/server-node.mjs @@ -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); diff --git a/packages/remix/src/build.ts b/packages/remix/src/build.ts index b5b28d272..62153254a 100644 --- a/packages/remix/src/build.ts +++ b/packages/remix/src/build.ts @@ -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(); + const staticConfigsMap = new Map(); 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(); - const nodeRoutes = new Set(); + const resolvedConfigsMap = new Map(); + 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(); 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 { 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 { 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; diff --git a/packages/remix/src/utils.ts b/packages/remix/src/utils.ts index 1ebc239e9..ba9b741b7 100644 --- a/packages/remix/src/utils.ts +++ b/packages/remix/src/utils.ts @@ -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 +): 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[] diff --git a/packages/remix/test/fixtures/01-remix-basics/app/b.server.ts b/packages/remix/test/fixtures/01-remix-basics/app/b.server.ts index 5e4ade669..270003cbf 100644 --- a/packages/remix/test/fixtures/01-remix-basics/app/b.server.ts +++ b/packages/remix/test/fixtures/01-remix-basics/app/b.server.ts @@ -8,10 +8,13 @@ import { json } from '@remix-run/node'; export async function loader() { const hi = await new Promise((resolve, reject) => { - exec('echo hi from the B page', (err, stdout) => { - if (err) return reject(err); - resolve(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 }); } diff --git a/packages/remix/test/fixtures/01-remix-basics/app/routes/b.tsx b/packages/remix/test/fixtures/01-remix-basics/app/routes/b.tsx index 23b623674..7880db356 100644 --- a/packages/remix/test/fixtures/01-remix-basics/app/routes/b.tsx +++ b/packages/remix/test/fixtures/01-remix-basics/app/routes/b.tsx @@ -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() { diff --git a/packages/remix/test/fixtures/01-remix-basics/probes.json b/packages/remix/test/fixtures/01-remix-basics/probes.json index f2ea61dd4..0f871b51d 100644 --- a/packages/remix/test/fixtures/01-remix-basics/probes.json +++ b/packages/remix/test/fixtures/01-remix-basics/probes.json @@ -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" }, diff --git a/packages/remix/test/unit.get-resolved-route-config.test.ts b/packages/remix/test/unit.get-resolved-route-config.test.ts new file mode 100644 index 000000000..499be1c9f --- /dev/null +++ b/packages/remix/test/unit.get-resolved-route-config.test.ts @@ -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([ + [{ 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); + }); +});