mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-09 21:07:46 +00:00
[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:
@@ -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) ?? {
|
||||
|
||||
106
packages/remix/src/hydrogen.ts
Normal file
106
packages/remix/src/hydrogen.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user