From 420c503f6c4f59cf3ffaf93f7945b4ed4c1b6728 Mon Sep 17 00:00:00 2001 From: Lorenzo <30781484+lorenzodejong@users.noreply.github.com> Date: Tue, 5 Sep 2023 21:51:17 +0200 Subject: [PATCH] Introduce a custom build-output-path and app-path argument for more flexible monorepo support. (#214) * Introduce a custom build-output-path and app-path argument for more flexible monorepo support. --- README.md | 20 +++++ examples/app-router/middleware.ts | 10 +++ .../src/adapters/plugins/routing/util.ts | 4 +- packages/open-next/src/build.ts | 86 +++++++++++++------ packages/open-next/src/index.ts | 2 + .../tests-e2e/tests/appRouter/isr.test.ts | 22 +++++ 6 files changed, 114 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 13887b62..e9d13e4e 100644 --- a/README.md +++ b/README.md @@ -616,6 +616,26 @@ await build({ }); ``` +#### Custom app and build output paths + +OpenNext runs the `build` script from your current command folder by default. When running OpenNext from a monorepo with decentralised application and build output paths, you can specify a custom `appPath` and/or `buildOutputPath`. This will allow you to execute your command from the root of the monorepo. + +```bash +# CLI +open-next build --build-command "pnpm custom:build" --app-path "./apps/example-app" --build-output-path "./dist/apps/example-app" +``` + +```ts +// JS +import { build } from "open-next/build.js"; + +await build({ + buildCommand: "pnpm custom:build", + appPath: "./apps/example-app", + buildOutputPath: "./dist/apps/example-app" +}); +``` + #### Minify server function Enabling this option will minimize all `.js` and `.json` files in the server function bundle using the [node-minify](https://github.com/srod/node-minify) library. This can reduce the size of the server function bundle by about 40%, depending on the size of your app. diff --git a/examples/app-router/middleware.ts b/examples/app-router/middleware.ts index ec46ac02..f502c7c9 100644 --- a/examples/app-router/middleware.ts +++ b/examples/app-router/middleware.ts @@ -28,6 +28,16 @@ export function middleware(request: NextRequest) { ); const responseHeaders = new Headers(); responseHeaders.set("response-header", "response-header"); + + // Set the cache control header with custom swr + // For: isr.test.ts + if (path === "/isr") { + responseHeaders.set( + "cache-control", + "max-age=10, stale-while-revalidate=999", + ); + } + const r = NextResponse.next({ headers: responseHeaders, request: { diff --git a/packages/open-next/src/adapters/plugins/routing/util.ts b/packages/open-next/src/adapters/plugins/routing/util.ts index 2ff22d6d..a8f54f10 100644 --- a/packages/open-next/src/adapters/plugins/routing/util.ts +++ b/packages/open-next/src/adapters/plugins/routing/util.ts @@ -56,9 +56,9 @@ export function fixCacheHeaderForHtmlPages( export function fixSWRCacheHeader(headers: Record) { // WORKAROUND: `NextServer` does not set correct SWR cache headers — https://github.com/serverless-stack/open-next#workaround-nextserver-does-not-set-correct-swr-cache-headers - if (headers["cache-control"]?.includes("stale-while-revalidate")) { + if (headers["cache-control"]) { headers["cache-control"] = headers["cache-control"].replace( - "stale-while-revalidate", + /\bstale-while-revalidate(?!=)/, "stale-while-revalidate=2592000", // 30 days ); } diff --git a/packages/open-next/src/build.ts b/packages/open-next/src/build.ts index 3dfd0199..61cdf6d1 100644 --- a/packages/open-next/src/build.ts +++ b/packages/open-next/src/build.ts @@ -35,6 +35,16 @@ interface BuildOptions { * ``` */ buildCommand?: string; + /** + * The path to the target folder of build output from the `buildCommand` option (the path which will contain the `.next` and `.open-next` folders). This path is relative from the current process.cwd(). + * @default "." + */ + buildOutputPath?: string; + /** + * The path to the root of the Next.js app's source code. This path is relative from the current process.cwd(). + * @default "." + */ + appPath?: string; } const require = topLevelCreateRequire(import.meta.url); @@ -46,14 +56,17 @@ export type PublicFiles = { }; export async function build(opts: BuildOptions = {}) { + const { root: monorepoRoot, packager } = findMonorepoRoot( + path.join(process.cwd(), opts.appPath || "."), + ); + // Initialize options - options = normalizeOptions(opts); + options = normalizeOptions(opts, monorepoRoot); // Pre-build validation checkRunningInsideNextjsApp(); printNextjsVersion(); printOpenNextVersion(); - const { root: monorepoRoot, packager } = findMonorepoRoot(); // Build Next.js app printHeader("Building Next.js app"); @@ -74,13 +87,17 @@ export async function build(opts: BuildOptions = {}) { } } -function normalizeOptions(opts: BuildOptions) { - const appPath = process.cwd(); - const outputDir = ".open-next"; +function normalizeOptions(opts: BuildOptions, root: string) { + const appPath = path.join(process.cwd(), opts.appPath || "."); + const buildOutputPath = path.join(process.cwd(), opts.buildOutputPath || "."); + const outputDir = path.join(buildOutputPath, ".open-next"); + const nextPackageJsonPath = findNextPackageJsonPath(appPath, root); return { openNextVersion: getOpenNextVersion(), - nextVersion: getNextVersion(appPath), + nextVersion: getNextVersion(nextPackageJsonPath), + nextPackageJsonPath, appPath, + appBuildOutputPath: buildOutputPath, appPublicPath: path.join(appPath, "public"), outputDir, tempDir: path.join(outputDir, ".build"), @@ -103,8 +120,7 @@ function checkRunningInsideNextjsApp() { } } -function findMonorepoRoot() { - const { appPath } = options; +function findMonorepoRoot(appPath: string) { let currentPath = appPath; while (currentPath !== "/") { const found = [ @@ -128,6 +144,13 @@ function findMonorepoRoot() { return { root: appPath, packager: "npm" as const }; } +function findNextPackageJsonPath(appPath: string, root: string) { + // This is needed for the case where the app is a single-version monorepo and the package.json is in the root of the monorepo + return fs.existsSync(path.join(appPath, "./package.json")) + ? path.join(appPath, "./package.json") + : path.join(root, "./package.json"); +} + function setStandaloneBuildMode(monorepoRoot: string) { // Equivalent to setting `target: "standalone"` in next.config.js process.env.NEXT_PRIVATE_STANDALONE = "true"; @@ -136,13 +159,13 @@ function setStandaloneBuildMode(monorepoRoot: string) { } function buildNextjsApp(packager: "npm" | "yarn" | "pnpm") { - const { appPath } = options; + const { nextPackageJsonPath } = options; const command = options.buildCommand ?? (packager === "npm" ? "npm run build" : `${packager} build`); cp.execSync(command, { stdio: "inherit", - cwd: appPath, + cwd: path.dirname(nextPackageJsonPath), }); } @@ -226,7 +249,7 @@ async function minifyServerBundle() { function createRevalidationBundle() { console.info(`Bundling revalidation function...`); - const { appPath, outputDir } = options; + const { appBuildOutputPath, outputDir } = options; // Create output folder const outputPath = path.join(outputDir, "revalidation-function"); @@ -241,7 +264,7 @@ function createRevalidationBundle() { // Copy over .next/prerender-manifest.json file fs.copyFileSync( - path.join(appPath, ".next", "prerender-manifest.json"), + path.join(appBuildOutputPath, ".next", "prerender-manifest.json"), path.join(outputPath, "prerender-manifest.json"), ); } @@ -249,7 +272,7 @@ function createRevalidationBundle() { function createImageOptimizationBundle() { console.info(`Bundling image optimization function...`); - const { appPath, outputDir } = options; + const { appPath, appBuildOutputPath, outputDir } = options; // Create output folder const outputPath = path.join(outputDir, "image-optimization-function"); @@ -289,7 +312,7 @@ function createImageOptimizationBundle() { // Copy over .next/required-server-files.json file fs.mkdirSync(path.join(outputPath, ".next")); fs.copyFileSync( - path.join(appPath, ".next/required-server-files.json"), + path.join(appBuildOutputPath, ".next/required-server-files.json"), path.join(outputPath, ".next/required-server-files.json"), ); @@ -310,7 +333,7 @@ function createImageOptimizationBundle() { function createStaticAssets() { console.info(`Bundling static assets...`); - const { appPath, appPublicPath, outputDir } = options; + const { appBuildOutputPath, appPublicPath, outputDir } = options; // Create output folder const outputPath = path.join(outputDir, "assets"); @@ -322,11 +345,11 @@ function createStaticAssets() { // - .next/static => _next/static // - public/* => * fs.copyFileSync( - path.join(appPath, ".next/BUILD_ID"), + path.join(appBuildOutputPath, ".next/BUILD_ID"), path.join(outputPath, "BUILD_ID"), ); fs.cpSync( - path.join(appPath, ".next/static"), + path.join(appBuildOutputPath, ".next/static"), path.join(outputPath, "_next", "static"), { recursive: true }, ); @@ -338,12 +361,16 @@ function createStaticAssets() { function createCacheAssets(monorepoRoot: string) { console.info(`Bundling cache assets...`); - const { appPath, outputDir } = options; - const packagePath = path.relative(monorepoRoot, appPath); - const buildId = getBuildId(appPath); + const { appBuildOutputPath, outputDir } = options; + const packagePath = path.relative(monorepoRoot, appBuildOutputPath); + const buildId = getBuildId(appBuildOutputPath); // Copy pages to cache folder - const dotNextPath = path.join(appPath, ".next/standalone", packagePath); + const dotNextPath = path.join( + appBuildOutputPath, + ".next/standalone", + packagePath, + ); const outputPath = path.join(outputDir, "cache", buildId); [".next/server/pages", ".next/server/app"] .map((dir) => path.join(dotNextPath, dir)) @@ -361,7 +388,10 @@ function createCacheAssets(monorepoRoot: string) { ); // Copy fetch-cache to cache folder - const fetchCachePath = path.join(appPath, ".next/cache/fetch-cache"); + const fetchCachePath = path.join( + appBuildOutputPath, + ".next/cache/fetch-cache", + ); if (fs.existsSync(fetchCachePath)) { const fetchOutputPath = path.join(outputDir, "cache", "__fetch", buildId); fs.mkdirSync(fetchOutputPath, { recursive: true }); @@ -376,7 +406,7 @@ function createCacheAssets(monorepoRoot: string) { async function createServerBundle(monorepoRoot: string) { console.info(`Bundling server function...`); - const { appPath, outputDir } = options; + const { appPath, appBuildOutputPath, outputDir } = options; // Create output folder const outputPath = path.join(outputDir, "server-function"); @@ -388,12 +418,12 @@ async function createServerBundle(monorepoRoot: string) { // `.next/standalone/package/path` (ie. `.next`, `server.js`). // We need to output the handler file inside the package path. const isMonorepo = monorepoRoot !== appPath; - const packagePath = path.relative(monorepoRoot, appPath); + const packagePath = path.relative(monorepoRoot, appBuildOutputPath); // Copy over standalone output files // note: if user uses pnpm as the package manager, node_modules contain // symlinks. We don't want to resolve the symlinks when copying. - fs.cpSync(path.join(appPath, ".next/standalone"), outputPath, { + fs.cpSync(path.join(appBuildOutputPath, ".next/standalone"), outputPath, { recursive: true, verbatimSymlinks: true, }); @@ -685,9 +715,9 @@ function getOpenNextVersion() { return require(path.join(__dirname, "../package.json")).version; } -function getNextVersion(appPath: string) { - const version = require(path.join(appPath, "./package.json")).dependencies - .next; +function getNextVersion(nextPackageJsonPath: string) { + const version = require(nextPackageJsonPath).dependencies.next; + // Drop the -canary.n suffix return version.split("-")[0]; } diff --git a/packages/open-next/src/index.ts b/packages/open-next/src/index.ts index 9f7fdb47..714a03f8 100644 --- a/packages/open-next/src/index.ts +++ b/packages/open-next/src/index.ts @@ -10,6 +10,8 @@ if (Object.keys(args).includes("--help")) printHelp(); build({ buildCommand: args["--build-command"], + buildOutputPath: args["--build-output-path"], + appPath: args["--app-path"], minify: Object.keys(args).includes("--minify"), }); diff --git a/packages/tests-e2e/tests/appRouter/isr.test.ts b/packages/tests-e2e/tests/appRouter/isr.test.ts index e72eef12..38983c8c 100644 --- a/packages/tests-e2e/tests/appRouter/isr.test.ts +++ b/packages/tests-e2e/tests/appRouter/isr.test.ts @@ -40,3 +40,25 @@ test("Incremental Static Regeneration", async ({ page }) => { expect(newTime).not.toEqual(finalTime); }); + +test("headers", async ({ page }) => { + let responsePromise = page.waitForResponse((response) => { + return response.status() === 200; + }); + await page.goto("/isr"); + + while (true) { + const response = await responsePromise; + const headers = response.headers(); + + // this was set in middleware + if (headers["cache-control"] === "max-age=10, stale-while-revalidate=999") { + break; + } + await wait(1000); + responsePromise = page.waitForResponse((response) => { + return response.status() === 200; + }); + await page.reload(); + } +});