[remix] Add initial support for Hydrogen v2 (#10305)

Enables support for Hydrogen v2 apps using the "Remix" preset. This initial support works with the Hydrogen demo store template unmodified, and all pages will use Edge functions.

Node.js runtime, and also any other configuration via `export const config`, are not supported at this time, due to some pending blockers.
This commit is contained in:
Nathan Rajlich
2023-08-08 14:39:57 -07:00
committed by GitHub
parent a8ecf40d6f
commit 0945d24cbe
108 changed files with 25865 additions and 12 deletions

View File

@@ -45,6 +45,7 @@ import {
ensureResolvable,
isESM,
} from './utils';
import { patchHydrogenServer } from './hydrogen';
interface ServerBundle {
serverBuildPath: string;
@@ -140,6 +141,10 @@ export const build: BuildV2 = async ({
await runNpmInstall(entrypointFsDirname, [], spawnOpts, meta, nodeVersion);
}
const isHydrogen2 =
pkg.dependencies?.['@shopify/remix-oxygen'] ||
pkg.devDependencies?.['@shopify/remix-oxygen'];
// Determine the version of Remix based on the `@remix-run/dev`
// package version.
const remixRunDevPath = await ensureResolvable(
@@ -162,7 +167,9 @@ export const build: BuildV2 = async ({
const depsToAdd: string[] = [];
if (remixRunDevPkg.name !== '@vercel/remix-run-dev') {
// Override the official `@remix-run/dev` package with the
// Vercel fork, which supports the `serverBundles` config
if (!isHydrogen2 && remixRunDevPkg.name !== '@vercel/remix-run-dev') {
const remixDevForkVersion = resolveSemverMinMax(
REMIX_RUN_DEV_MIN_VERSION,
REMIX_RUN_DEV_MAX_VERSION,
@@ -216,6 +223,8 @@ export const build: BuildV2 = async ({
}
let remixConfigWrapped = false;
let serverEntryPointAbs: string | undefined;
let originalServerEntryPoint: string | undefined;
const remixConfigPath = findConfig(entrypointFsDirname, 'remix.config');
const renamedRemixConfigPath = remixConfigPath
? `${remixConfigPath}.original${extname(remixConfigPath)}`
@@ -232,7 +241,13 @@ export const build: BuildV2 = async ({
const staticConfigsMap = new Map<ConfigRoute, BaseFunctionConfig | null>();
for (const route of remixRoutes) {
const routePath = join(remixConfig.appDirectory, route.file);
const staticConfig = getConfig(project, routePath);
let staticConfig = getConfig(project, routePath);
if (staticConfig && isHydrogen2) {
console.log(
'WARN: `export const config` is currently not supported for Hydrogen v2 apps'
);
staticConfig = null;
}
staticConfigsMap.set(route, staticConfig);
}
@@ -240,7 +255,8 @@ export const build: BuildV2 = async ({
const config = getResolvedRouteConfig(
route,
remixConfig.routes,
staticConfigsMap
staticConfigsMap,
isHydrogen2
);
resolvedConfigsMap.set(route, config);
}
@@ -269,7 +285,12 @@ export const build: BuildV2 = async ({
([hash, routes]) => {
const runtime = resolvedConfigsMap.get(routes[0])?.runtime ?? 'nodejs';
return {
serverBuildPath: `build/build-${runtime}-${hash}.js`,
serverBuildPath: isHydrogen2
? relative(entrypointFsDirname, remixConfig.serverBuildPath)
: `${relative(
entrypointFsDirname,
dirname(remixConfig.serverBuildPath)
)}/build-${runtime}-${hash}.js`,
routes: routes.map(r => r.id),
};
}
@@ -277,7 +298,7 @@ export const build: BuildV2 = async ({
// 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
if (remixConfigPath && renamedRemixConfigPath) {
if (!isHydrogen2 && remixConfigPath && renamedRemixConfigPath) {
await fs.rename(remixConfigPath, renamedRemixConfigPath);
let patchedConfig: string;
@@ -307,6 +328,32 @@ module.exports = config;`;
remixConfigWrapped = true;
}
// For Hydrogen v2, patch the `server.ts` file to be Vercel-compatible
if (isHydrogen2) {
if (remixConfig.serverEntryPoint) {
serverEntryPointAbs = join(
entrypointFsDirname,
remixConfig.serverEntryPoint
);
originalServerEntryPoint = await fs.readFile(
serverEntryPointAbs,
'utf8'
);
const patchedServerEntryPoint = patchHydrogenServer(
project,
serverEntryPointAbs
);
if (patchedServerEntryPoint) {
debug(
`Patched Hydrogen server file: ${remixConfig.serverEntryPoint}`
);
await fs.writeFile(serverEntryPointAbs, patchedServerEntryPoint);
}
} else {
console.log('WARN: No "server" field found in Remix config');
}
}
// Make `remix build` output production mode
spawnOpts.env.NODE_ENV = 'production';
@@ -336,10 +383,28 @@ module.exports = config;`;
}
}
} finally {
const cleanupOps: Promise<void>[] = [];
// Clean up our patched `remix.config.js` to be polite
if (remixConfigWrapped && remixConfigPath && renamedRemixConfigPath) {
await fs.rename(renamedRemixConfigPath, remixConfigPath);
cleanupOps.push(
fs
.rename(renamedRemixConfigPath, remixConfigPath)
.then(() =>
debug(`Restored original "${basename(remixConfigPath)}" file`)
)
);
}
// Restore original server entrypoint if it was modified (for Hydrogen v2)
if (serverEntryPointAbs && originalServerEntryPoint) {
cleanupOps.push(
fs
.writeFile(serverEntryPointAbs, originalServerEntryPoint)
.then(() =>
debug(`Restored original "${basename(serverEntryPointAbs!)}" file`)
)
);
}
await Promise.all(cleanupOps);
}
// This needs to happen before we run NFT to create the Node/Edge functions
@@ -349,11 +414,21 @@ module.exports = config;`;
repoRootPath,
'@remix-run/server-runtime'
),
ensureResolvable(entrypointFsDirname, repoRootPath, '@remix-run/node'),
!isHydrogen2
? ensureResolvable(entrypointFsDirname, repoRootPath, '@remix-run/node')
: null,
]);
const staticDir = join(
remixConfig.assetsBuildDirectory,
...remixConfig.publicPath
.replace(/^\/|\/$/g, '')
.split('/')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(_ => '..')
);
const [staticFiles, ...functions] = await Promise.all([
glob('**', join(entrypointFsDirname, 'public')),
glob('**', staticDir),
...serverBundles.map(bundle => {
const firstRoute = remixConfig.routes[bundle.routes[0]];
const config = resolvedConfigsMap.get(firstRoute) ?? {

View File

@@ -0,0 +1,106 @@
import { basename } from 'path';
import { Node, Project, SyntaxKind } from 'ts-morph';
/**
* For Hydrogen v2, the `server.ts` file exports a signature like:
*
* ```
* export default {
* async fetch(
* request: Request,
* env: Env,
* executionContext: ExecutionContext,
* ): Promise<Response>;
* }
* ```
*
* Here we parse the AST of that file so that we can:
*
* 1. Convert the signature to be compatible with Vercel Edge functions
* (i.e. `export default (res: Response): Promise<Response>`).
*
* 2. Track usages of the `env` parameter which (which gets removed),
* so that we can create that object based on `process.env`.
*/
export function patchHydrogenServer(
project: Project,
serverEntryPoint: string
) {
const sourceFile = project.addSourceFileAtPath(serverEntryPoint);
const defaultExportSymbol = sourceFile.getDescendantsOfKind(
SyntaxKind.ExportAssignment
)[0];
const envProperties: string[] = [];
if (!defaultExportSymbol) {
console.log(
`WARN: No default export found in "${basename(serverEntryPoint)}"`
);
return;
}
const objectLiteral = defaultExportSymbol.getFirstChildByKind(
SyntaxKind.ObjectLiteralExpression
);
if (!Node.isObjectLiteralExpression(objectLiteral)) {
console.log(
`WARN: Default export in "${basename(
serverEntryPoint
)}" does not conform to Oxygen syntax`
);
return;
}
const fetchMethod = objectLiteral.getProperty('fetch');
if (!fetchMethod || !Node.isMethodDeclaration(fetchMethod)) {
console.log(
`WARN: Default export in "${basename(
serverEntryPoint
)}" does not conform to Oxygen syntax`
);
return;
}
const parameters = fetchMethod.getParameters();
// Find usages of the env object within the fetch method
const envParam = parameters[1];
const envParamName = envParam.getName();
if (envParam) {
fetchMethod.forEachDescendant(node => {
if (
Node.isPropertyAccessExpression(node) &&
node.getExpression().getText() === envParamName
) {
envProperties.push(node.getName());
}
});
}
// Vercel does not support the Web Cache API, so find
// and replace `caches.open()` calls with `undefined`
fetchMethod.forEachDescendant(node => {
if (
Node.isCallExpression(node) &&
node.getExpression().getText() === 'caches.open'
) {
node.replaceWithText(`undefined /* ${node.getText()} */`);
}
});
// Remove the 'env' parameter to match Vercel's Edge signature
parameters.splice(1, 1);
// Construct the new function with the parameters and body of the original fetch method
const newFunction = `export default async function(${parameters
.map(p => p.getText())
.join(', ')}) ${fetchMethod.getBody()!.getText()}`;
defaultExportSymbol.replaceWithText(newFunction);
const envCode = `const env = { ${envProperties
.map(name => `${name}: process.env.${name}`)
.join(', ')} };`;
const updatedCodeString = sourceFile.getFullText();
return `${envCode}\n${updatedCodeString}`;
}

View File

@@ -78,7 +78,8 @@ function isEdgeRuntime(runtime: string): boolean {
export function getResolvedRouteConfig(
route: ConfigRoute,
routes: RouteManifest,
configs: Map<ConfigRoute, BaseFunctionConfig | null>
configs: Map<ConfigRoute, BaseFunctionConfig | null>,
isHydrogen2: boolean
): ResolvedRouteConfig {
let runtime: ResolvedRouteConfig['runtime'] | undefined;
let regions: ResolvedRouteConfig['regions'];
@@ -107,8 +108,8 @@ export function getResolvedRouteConfig(
regions = Array.from(new Set(regions)).sort();
}
if (runtime === 'edge') {
return { runtime, regions };
if (isHydrogen2 || runtime === 'edge') {
return { runtime: 'edge', regions };
}
if (regions && !Array.isArray(regions)) {