mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-11 04:22:13 +00:00
[remix] Split Edge and Node server builds using serverBundles config (#9504)
Utilize the [`serverBundles`](https://github.com/remix-run/remix/pull/5479) config option to generate two server bundle builds. One contains only the routes that should run in Node.js, and the other contains only the routes that should run in the Edge runtime. In the future we could update this configuration to generate more than two bundles to be more granular and allow for infinite scalability. Because the `serverBundles` PR is not yet merged, this PR introduces usage of a forked version of `@remix-run/dev` which incorporates our changes. Hopefully usage of a fork is temporary, but it gets us unblocked for now.
This commit is contained in:
@@ -22,7 +22,7 @@
|
|||||||
"vercel-edge-entrypoint.js"
|
"vercel-edge-entrypoint.js"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/dev": "1.13.0",
|
"@remix-run/dev": "npm:@vercel/remix-run-dev@1.13.0-patch.1",
|
||||||
"@vercel/nft": "0.22.5",
|
"@vercel/nft": "0.22.5",
|
||||||
"@vercel/static-config": "2.0.13",
|
"@vercel/static-config": "2.0.13",
|
||||||
"path-to-regexp": "6.2.1",
|
"path-to-regexp": "6.2.1",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { createRequestHandler } from '@remix-run/server-runtime';
|
import { createRequestHandler } from '@remix-run/server-runtime';
|
||||||
import build from './index.js';
|
import build from './build-edge.js';
|
||||||
export default createRequestHandler(build);
|
export default createRequestHandler(build);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
|
|
||||||
installGlobals();
|
installGlobals();
|
||||||
|
|
||||||
import build from './index.js';
|
import build from './build-node.js';
|
||||||
|
|
||||||
const handleRequest = createRemixRequestHandler(build, process.env.NODE_ENV);
|
const handleRequest = createRemixRequestHandler(build, process.env.NODE_ENV);
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ import {
|
|||||||
|
|
||||||
const _require: typeof require = eval('require');
|
const _require: typeof require = eval('require');
|
||||||
|
|
||||||
|
const REMIX_RUN_DEV_PATH = dirname(
|
||||||
|
_require.resolve('@remix-run/dev/package.json')
|
||||||
|
);
|
||||||
|
|
||||||
export const build: BuildV2 = async ({
|
export const build: BuildV2 = async ({
|
||||||
entrypoint,
|
entrypoint,
|
||||||
files,
|
files,
|
||||||
@@ -101,19 +105,84 @@ export const build: BuildV2 = async ({
|
|||||||
await fs.readFile(remixDevPackageJsonPath, 'utf8')
|
await fs.readFile(remixDevPackageJsonPath, 'utf8')
|
||||||
).version;
|
).version;
|
||||||
|
|
||||||
|
// Make our version of `remix` CLI available to the project's build
|
||||||
|
// command by creating a symlink to the copy in our node modules,
|
||||||
|
// so that `serverBundles` works: https://github.com/remix-run/remix/pull/5479
|
||||||
|
const nodeModulesDir = join(entrypointFsDirname, 'node_modules');
|
||||||
|
const remixRunDevPath = join(nodeModulesDir, '@remix-run/dev');
|
||||||
|
let backupRemixRunDevPath:
|
||||||
|
| string
|
||||||
|
| false = `${remixRunDevPath}.__vercel_backup`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.rename(remixRunDevPath, backupRemixRunDevPath);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
backupRemixRunDevPath = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.symlink(REMIX_RUN_DEV_PATH, remixRunDevPath);
|
||||||
|
|
||||||
// Make `remix build` output production mode
|
// Make `remix build` output production mode
|
||||||
spawnOpts.env.NODE_ENV = 'production';
|
spawnOpts.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
const remixConfig = await chdirAndReadConfig(entrypointFsDirname);
|
const remixConfig = await chdirAndReadConfig(entrypointFsDirname);
|
||||||
const remixRoutes = Object.values(remixConfig.routes);
|
const remixRoutes = Object.values(remixConfig.routes);
|
||||||
|
|
||||||
|
// Read the `export const config` (if any) for each route
|
||||||
|
const project = new Project();
|
||||||
|
const staticConfigsMap = new Map<ConfigRoute, BaseFunctionConfig>();
|
||||||
|
for (const route of remixRoutes) {
|
||||||
|
const routePath = join(remixConfig.appDirectory, route.file);
|
||||||
|
const staticConfig = getConfig(project, routePath);
|
||||||
|
if (staticConfig) {
|
||||||
|
staticConfigsMap.set(route, staticConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Figure out which pages should be edge functions
|
||||||
|
const edgeRoutes = new Set<ConfigRoute>();
|
||||||
|
const nodeRoutes = new Set<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdge) {
|
||||||
|
edgeRoutes.add(route);
|
||||||
|
} else {
|
||||||
|
nodeRoutes.add(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),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// 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
|
||||||
const remixConfigPath = findConfig(entrypointFsDirname, 'remix.config');
|
const remixConfigPath = findConfig(entrypointFsDirname, 'remix.config');
|
||||||
const renamedRemixConfigPath = remixConfigPath
|
const renamedRemixConfigPath = remixConfigPath
|
||||||
? `${remixConfigPath}.original${extname(remixConfigPath)}`
|
? `${remixConfigPath}.original${extname(remixConfigPath)}`
|
||||||
: undefined;
|
: undefined;
|
||||||
const serverBuildPath = 'build/index.js';
|
|
||||||
if (remixConfigPath && renamedRemixConfigPath) {
|
if (remixConfigPath && renamedRemixConfigPath) {
|
||||||
await fs.rename(remixConfigPath, renamedRemixConfigPath);
|
await fs.rename(remixConfigPath, renamedRemixConfigPath);
|
||||||
|
|
||||||
@@ -133,7 +202,8 @@ export const build: BuildV2 = async ({
|
|||||||
config.serverBuildTarget = undefined;
|
config.serverBuildTarget = undefined;
|
||||||
config.serverModuleFormat = 'cjs';
|
config.serverModuleFormat = 'cjs';
|
||||||
config.serverPlatform = 'node';
|
config.serverPlatform = 'node';
|
||||||
config.serverBuildPath = ${JSON.stringify(serverBuildPath)}
|
config.serverBuildPath = undefined;
|
||||||
|
config.serverBundles = ${JSON.stringify(serverBundles)};
|
||||||
export default config;`;
|
export default config;`;
|
||||||
} else {
|
} else {
|
||||||
patchedConfig = `const config = require('./${basename(
|
patchedConfig = `const config = require('./${basename(
|
||||||
@@ -142,7 +212,8 @@ export default config;`;
|
|||||||
config.serverBuildTarget = undefined;
|
config.serverBuildTarget = undefined;
|
||||||
config.serverModuleFormat = 'cjs';
|
config.serverModuleFormat = 'cjs';
|
||||||
config.serverPlatform = 'node';
|
config.serverPlatform = 'node';
|
||||||
config.serverBuildPath = ${JSON.stringify(serverBuildPath)}
|
config.serverBuildPath = undefined;
|
||||||
|
config.serverBundles = ${JSON.stringify(serverBundles)};
|
||||||
module.exports = config;`;
|
module.exports = config;`;
|
||||||
}
|
}
|
||||||
await fs.writeFile(remixConfigPath, patchedConfig);
|
await fs.writeFile(remixConfigPath, patchedConfig);
|
||||||
@@ -182,20 +253,12 @@ module.exports = config;`;
|
|||||||
if (remixConfigPath && renamedRemixConfigPath) {
|
if (remixConfigPath && renamedRemixConfigPath) {
|
||||||
await fs.rename(renamedRemixConfigPath, remixConfigPath);
|
await fs.rename(renamedRemixConfigPath, remixConfigPath);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Figure out which pages should be edge functions
|
// Remove `@remix-run/dev` symlink
|
||||||
let hasEdgeRoute = false;
|
await fs.unlink(remixRunDevPath);
|
||||||
const staticConfigsMap = new Map<ConfigRoute, BaseFunctionConfig>();
|
if (backupRemixRunDevPath) {
|
||||||
const project = new Project();
|
// Restore previous version if it was existed
|
||||||
for (const route of remixRoutes) {
|
await fs.rename(backupRemixRunDevPath, remixRunDevPath);
|
||||||
const routePath = join(remixConfig.appDirectory, route.file);
|
|
||||||
const staticConfig = getConfig(project, routePath);
|
|
||||||
if (staticConfig) {
|
|
||||||
staticConfigsMap.set(route, staticConfig);
|
|
||||||
}
|
|
||||||
if (staticConfig?.runtime && isEdgeRuntime(staticConfig.runtime)) {
|
|
||||||
hasEdgeRoute = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,18 +275,18 @@ module.exports = config;`;
|
|||||||
const [staticFiles, nodeFunction, edgeFunction] = await Promise.all([
|
const [staticFiles, nodeFunction, edgeFunction] = await Promise.all([
|
||||||
glob('**', join(entrypointFsDirname, 'public')),
|
glob('**', join(entrypointFsDirname, 'public')),
|
||||||
createRenderNodeFunction(
|
createRenderNodeFunction(
|
||||||
|
nodeVersion,
|
||||||
entrypointFsDirname,
|
entrypointFsDirname,
|
||||||
repoRootPath,
|
repoRootPath,
|
||||||
join(entrypointFsDirname, serverBuildPath),
|
join(entrypointFsDirname, 'build/build-node.js'),
|
||||||
remixConfig.serverEntryPoint,
|
remixConfig.serverEntryPoint,
|
||||||
nodeVersion,
|
|
||||||
remixVersion
|
remixVersion
|
||||||
),
|
),
|
||||||
hasEdgeRoute
|
edgeRoutes.size > 0
|
||||||
? createRenderEdgeFunction(
|
? createRenderEdgeFunction(
|
||||||
entrypointFsDirname,
|
entrypointFsDirname,
|
||||||
repoRootPath,
|
repoRootPath,
|
||||||
join(entrypointFsDirname, serverBuildPath),
|
join(entrypointFsDirname, 'build/build-edge.js'),
|
||||||
remixConfig.serverEntryPoint,
|
remixConfig.serverEntryPoint,
|
||||||
remixVersion
|
remixVersion
|
||||||
)
|
)
|
||||||
@@ -254,17 +317,8 @@ module.exports = config;`;
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 fn =
|
const fn =
|
||||||
isEdge && edgeFunction
|
edgeRoutes.has(route) && 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
|
||||||
@@ -307,11 +361,11 @@ function hasScript(scriptName: string, pkg: PackageJson | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createRenderNodeFunction(
|
async function createRenderNodeFunction(
|
||||||
|
nodeVersion: NodeVersion,
|
||||||
entrypointDir: string,
|
entrypointDir: string,
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
serverBuildPath: string,
|
serverBuildPath: string,
|
||||||
serverEntryPoint: string | undefined,
|
serverEntryPoint: string | undefined,
|
||||||
nodeVersion: NodeVersion,
|
|
||||||
remixVersion: string
|
remixVersion: string
|
||||||
): Promise<NodejsLambda> {
|
): Promise<NodejsLambda> {
|
||||||
const files: Files = {};
|
const files: Files = {};
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ export function getPathFromRoute(
|
|||||||
route: ConfigRoute,
|
route: ConfigRoute,
|
||||||
routes: RouteManifest
|
routes: RouteManifest
|
||||||
): string {
|
): string {
|
||||||
if (route.id === 'root' || (route.parentId === 'root' && route.index)) {
|
if (
|
||||||
|
route.id === 'root' ||
|
||||||
|
(route.parentId === 'root' && !route.path && route.index)
|
||||||
|
) {
|
||||||
return 'index';
|
return 'index';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
packages/remix/test/fixtures/01-remix-basics/app/b.server.ts
vendored
Normal file
17
packages/remix/test/fixtures/01-remix-basics/app/b.server.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Edge functions can not use child processes, but this is route
|
||||||
|
// uses Node.js. So this is here to verify that bundle splitting
|
||||||
|
// is working correctly (because this route should not exist in
|
||||||
|
// the Edge bundle).
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve(stdout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return json({ hi });
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
|
import { loader } from '~/b.server';
|
||||||
|
import { useLoaderData } from '@remix-run/react';
|
||||||
|
|
||||||
|
export { loader };
|
||||||
|
|
||||||
export default function B() {
|
export default function B() {
|
||||||
|
const { hi } = useLoaderData<typeof loader>();
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
|
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
|
||||||
<h1>B page</h1>
|
<h1>B page</h1>
|
||||||
|
<p>{hi}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "B page" },
|
{ "path": "/b", "mustContain": "hi from the B page" },
|
||||||
{ "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" },
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
"start": "remix-serve build"
|
"start": "remix-serve build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/react": "^1.7.4",
|
"@remix-run/react": "1.5.0",
|
||||||
"@remix-run/serve": "^1.7.4",
|
"@remix-run/serve": "1.5.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0"
|
"react-dom": "18.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@remix-run/dev": "^1.7.4",
|
"@remix-run/dev": "1.5.0",
|
||||||
"@types/react": "^17.0.45",
|
"@types/react": "^17.0.45",
|
||||||
"@types/react-dom": "^17.0.17",
|
"@types/react-dom": "^17.0.17",
|
||||||
"typescript": "^4.6.4"
|
"typescript": "^4.6.4"
|
||||||
|
|||||||
3281
packages/remix/test/fixtures/03-with-pnpm/pnpm-lock.yaml
generated
vendored
3281
packages/remix/test/fixtures/03-with-pnpm/pnpm-lock.yaml
generated
vendored
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,14 @@ describe('getPathFromRoute()', () => {
|
|||||||
parentId: 'root',
|
parentId: 'root',
|
||||||
file: 'routes/$.tsx',
|
file: 'routes/$.tsx',
|
||||||
},
|
},
|
||||||
|
'routes/nested/index': {
|
||||||
|
path: 'nested',
|
||||||
|
index: true,
|
||||||
|
caseSensitive: undefined,
|
||||||
|
id: 'routes/nested/index',
|
||||||
|
parentId: 'root',
|
||||||
|
file: 'routes/nested/index.tsx',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -79,6 +87,7 @@ describe('getPathFromRoute()', () => {
|
|||||||
{ id: 'routes/__pathless', expected: '' },
|
{ id: 'routes/__pathless', expected: '' },
|
||||||
{ id: 'routes/index', expected: 'index' },
|
{ id: 'routes/index', expected: 'index' },
|
||||||
{ id: 'routes/api.hello', expected: 'api/hello' },
|
{ id: 'routes/api.hello', expected: 'api/hello' },
|
||||||
|
{ id: 'routes/nested/index', expected: 'nested' },
|
||||||
{ id: 'routes/projects', expected: 'projects' },
|
{ id: 'routes/projects', expected: 'projects' },
|
||||||
{ id: 'routes/projects/__pathless', expected: 'projects' },
|
{ id: 'routes/projects/__pathless', expected: 'projects' },
|
||||||
{ id: 'routes/projects/index', expected: 'projects' },
|
{ id: 'routes/projects/index', expected: 'projects' },
|
||||||
|
|||||||
514
pnpm-lock.yaml
generated
514
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user