Skip to content

Commit 420c503

Browse files
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.
1 parent 010edc2 commit 420c503

File tree

6 files changed

+114
-30
lines changed

6 files changed

+114
-30
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,26 @@ await build({
616616
});
617617
```
618618

619+
#### Custom app and build output paths
620+
621+
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.
622+
623+
```bash
624+
# CLI
625+
open-next build --build-command "pnpm custom:build" --app-path "./apps/example-app" --build-output-path "./dist/apps/example-app"
626+
```
627+
628+
```ts
629+
// JS
630+
import { build } from "open-next/build.js";
631+
632+
await build({
633+
buildCommand: "pnpm custom:build",
634+
appPath: "./apps/example-app",
635+
buildOutputPath: "./dist/apps/example-app"
636+
});
637+
```
638+
619639
#### Minify server function
620640

621641
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.

examples/app-router/middleware.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ export function middleware(request: NextRequest) {
2828
);
2929
const responseHeaders = new Headers();
3030
responseHeaders.set("response-header", "response-header");
31+
32+
// Set the cache control header with custom swr
33+
// For: isr.test.ts
34+
if (path === "/isr") {
35+
responseHeaders.set(
36+
"cache-control",
37+
"max-age=10, stale-while-revalidate=999",
38+
);
39+
}
40+
3141
const r = NextResponse.next({
3242
headers: responseHeaders,
3343
request: {

packages/open-next/src/adapters/plugins/routing/util.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ export function fixCacheHeaderForHtmlPages(
5656

5757
export function fixSWRCacheHeader(headers: Record<string, string | undefined>) {
5858
// 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
59-
if (headers["cache-control"]?.includes("stale-while-revalidate")) {
59+
if (headers["cache-control"]) {
6060
headers["cache-control"] = headers["cache-control"].replace(
61-
"stale-while-revalidate",
61+
/\bstale-while-revalidate(?!=)/,
6262
"stale-while-revalidate=2592000", // 30 days
6363
);
6464
}

packages/open-next/src/build.ts

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ interface BuildOptions {
3535
* ```
3636
*/
3737
buildCommand?: string;
38+
/**
39+
* 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().
40+
* @default "."
41+
*/
42+
buildOutputPath?: string;
43+
/**
44+
* The path to the root of the Next.js app's source code. This path is relative from the current process.cwd().
45+
* @default "."
46+
*/
47+
appPath?: string;
3848
}
3949

4050
const require = topLevelCreateRequire(import.meta.url);
@@ -46,14 +56,17 @@ export type PublicFiles = {
4656
};
4757

4858
export async function build(opts: BuildOptions = {}) {
59+
const { root: monorepoRoot, packager } = findMonorepoRoot(
60+
path.join(process.cwd(), opts.appPath || "."),
61+
);
62+
4963
// Initialize options
50-
options = normalizeOptions(opts);
64+
options = normalizeOptions(opts, monorepoRoot);
5165

5266
// Pre-build validation
5367
checkRunningInsideNextjsApp();
5468
printNextjsVersion();
5569
printOpenNextVersion();
56-
const { root: monorepoRoot, packager } = findMonorepoRoot();
5770

5871
// Build Next.js app
5972
printHeader("Building Next.js app");
@@ -74,13 +87,17 @@ export async function build(opts: BuildOptions = {}) {
7487
}
7588
}
7689

77-
function normalizeOptions(opts: BuildOptions) {
78-
const appPath = process.cwd();
79-
const outputDir = ".open-next";
90+
function normalizeOptions(opts: BuildOptions, root: string) {
91+
const appPath = path.join(process.cwd(), opts.appPath || ".");
92+
const buildOutputPath = path.join(process.cwd(), opts.buildOutputPath || ".");
93+
const outputDir = path.join(buildOutputPath, ".open-next");
94+
const nextPackageJsonPath = findNextPackageJsonPath(appPath, root);
8095
return {
8196
openNextVersion: getOpenNextVersion(),
82-
nextVersion: getNextVersion(appPath),
97+
nextVersion: getNextVersion(nextPackageJsonPath),
98+
nextPackageJsonPath,
8399
appPath,
100+
appBuildOutputPath: buildOutputPath,
84101
appPublicPath: path.join(appPath, "public"),
85102
outputDir,
86103
tempDir: path.join(outputDir, ".build"),
@@ -103,8 +120,7 @@ function checkRunningInsideNextjsApp() {
103120
}
104121
}
105122

106-
function findMonorepoRoot() {
107-
const { appPath } = options;
123+
function findMonorepoRoot(appPath: string) {
108124
let currentPath = appPath;
109125
while (currentPath !== "/") {
110126
const found = [
@@ -128,6 +144,13 @@ function findMonorepoRoot() {
128144
return { root: appPath, packager: "npm" as const };
129145
}
130146

147+
function findNextPackageJsonPath(appPath: string, root: string) {
148+
// 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
149+
return fs.existsSync(path.join(appPath, "./package.json"))
150+
? path.join(appPath, "./package.json")
151+
: path.join(root, "./package.json");
152+
}
153+
131154
function setStandaloneBuildMode(monorepoRoot: string) {
132155
// Equivalent to setting `target: "standalone"` in next.config.js
133156
process.env.NEXT_PRIVATE_STANDALONE = "true";
@@ -136,13 +159,13 @@ function setStandaloneBuildMode(monorepoRoot: string) {
136159
}
137160

138161
function buildNextjsApp(packager: "npm" | "yarn" | "pnpm") {
139-
const { appPath } = options;
162+
const { nextPackageJsonPath } = options;
140163
const command =
141164
options.buildCommand ??
142165
(packager === "npm" ? "npm run build" : `${packager} build`);
143166
cp.execSync(command, {
144167
stdio: "inherit",
145-
cwd: appPath,
168+
cwd: path.dirname(nextPackageJsonPath),
146169
});
147170
}
148171

@@ -226,7 +249,7 @@ async function minifyServerBundle() {
226249
function createRevalidationBundle() {
227250
console.info(`Bundling revalidation function...`);
228251

229-
const { appPath, outputDir } = options;
252+
const { appBuildOutputPath, outputDir } = options;
230253

231254
// Create output folder
232255
const outputPath = path.join(outputDir, "revalidation-function");
@@ -241,15 +264,15 @@ function createRevalidationBundle() {
241264

242265
// Copy over .next/prerender-manifest.json file
243266
fs.copyFileSync(
244-
path.join(appPath, ".next", "prerender-manifest.json"),
267+
path.join(appBuildOutputPath, ".next", "prerender-manifest.json"),
245268
path.join(outputPath, "prerender-manifest.json"),
246269
);
247270
}
248271

249272
function createImageOptimizationBundle() {
250273
console.info(`Bundling image optimization function...`);
251274

252-
const { appPath, outputDir } = options;
275+
const { appPath, appBuildOutputPath, outputDir } = options;
253276

254277
// Create output folder
255278
const outputPath = path.join(outputDir, "image-optimization-function");
@@ -289,7 +312,7 @@ function createImageOptimizationBundle() {
289312
// Copy over .next/required-server-files.json file
290313
fs.mkdirSync(path.join(outputPath, ".next"));
291314
fs.copyFileSync(
292-
path.join(appPath, ".next/required-server-files.json"),
315+
path.join(appBuildOutputPath, ".next/required-server-files.json"),
293316
path.join(outputPath, ".next/required-server-files.json"),
294317
);
295318

@@ -310,7 +333,7 @@ function createImageOptimizationBundle() {
310333
function createStaticAssets() {
311334
console.info(`Bundling static assets...`);
312335

313-
const { appPath, appPublicPath, outputDir } = options;
336+
const { appBuildOutputPath, appPublicPath, outputDir } = options;
314337

315338
// Create output folder
316339
const outputPath = path.join(outputDir, "assets");
@@ -322,11 +345,11 @@ function createStaticAssets() {
322345
// - .next/static => _next/static
323346
// - public/* => *
324347
fs.copyFileSync(
325-
path.join(appPath, ".next/BUILD_ID"),
348+
path.join(appBuildOutputPath, ".next/BUILD_ID"),
326349
path.join(outputPath, "BUILD_ID"),
327350
);
328351
fs.cpSync(
329-
path.join(appPath, ".next/static"),
352+
path.join(appBuildOutputPath, ".next/static"),
330353
path.join(outputPath, "_next", "static"),
331354
{ recursive: true },
332355
);
@@ -338,12 +361,16 @@ function createStaticAssets() {
338361
function createCacheAssets(monorepoRoot: string) {
339362
console.info(`Bundling cache assets...`);
340363

341-
const { appPath, outputDir } = options;
342-
const packagePath = path.relative(monorepoRoot, appPath);
343-
const buildId = getBuildId(appPath);
364+
const { appBuildOutputPath, outputDir } = options;
365+
const packagePath = path.relative(monorepoRoot, appBuildOutputPath);
366+
const buildId = getBuildId(appBuildOutputPath);
344367

345368
// Copy pages to cache folder
346-
const dotNextPath = path.join(appPath, ".next/standalone", packagePath);
369+
const dotNextPath = path.join(
370+
appBuildOutputPath,
371+
".next/standalone",
372+
packagePath,
373+
);
347374
const outputPath = path.join(outputDir, "cache", buildId);
348375
[".next/server/pages", ".next/server/app"]
349376
.map((dir) => path.join(dotNextPath, dir))
@@ -361,7 +388,10 @@ function createCacheAssets(monorepoRoot: string) {
361388
);
362389

363390
// Copy fetch-cache to cache folder
364-
const fetchCachePath = path.join(appPath, ".next/cache/fetch-cache");
391+
const fetchCachePath = path.join(
392+
appBuildOutputPath,
393+
".next/cache/fetch-cache",
394+
);
365395
if (fs.existsSync(fetchCachePath)) {
366396
const fetchOutputPath = path.join(outputDir, "cache", "__fetch", buildId);
367397
fs.mkdirSync(fetchOutputPath, { recursive: true });
@@ -376,7 +406,7 @@ function createCacheAssets(monorepoRoot: string) {
376406
async function createServerBundle(monorepoRoot: string) {
377407
console.info(`Bundling server function...`);
378408

379-
const { appPath, outputDir } = options;
409+
const { appPath, appBuildOutputPath, outputDir } = options;
380410

381411
// Create output folder
382412
const outputPath = path.join(outputDir, "server-function");
@@ -388,12 +418,12 @@ async function createServerBundle(monorepoRoot: string) {
388418
// `.next/standalone/package/path` (ie. `.next`, `server.js`).
389419
// We need to output the handler file inside the package path.
390420
const isMonorepo = monorepoRoot !== appPath;
391-
const packagePath = path.relative(monorepoRoot, appPath);
421+
const packagePath = path.relative(monorepoRoot, appBuildOutputPath);
392422

393423
// Copy over standalone output files
394424
// note: if user uses pnpm as the package manager, node_modules contain
395425
// symlinks. We don't want to resolve the symlinks when copying.
396-
fs.cpSync(path.join(appPath, ".next/standalone"), outputPath, {
426+
fs.cpSync(path.join(appBuildOutputPath, ".next/standalone"), outputPath, {
397427
recursive: true,
398428
verbatimSymlinks: true,
399429
});
@@ -685,9 +715,9 @@ function getOpenNextVersion() {
685715
return require(path.join(__dirname, "../package.json")).version;
686716
}
687717

688-
function getNextVersion(appPath: string) {
689-
const version = require(path.join(appPath, "./package.json")).dependencies
690-
.next;
718+
function getNextVersion(nextPackageJsonPath: string) {
719+
const version = require(nextPackageJsonPath).dependencies.next;
720+
691721
// Drop the -canary.n suffix
692722
return version.split("-")[0];
693723
}

packages/open-next/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ if (Object.keys(args).includes("--help")) printHelp();
1010

1111
build({
1212
buildCommand: args["--build-command"],
13+
buildOutputPath: args["--build-output-path"],
14+
appPath: args["--app-path"],
1315
minify: Object.keys(args).includes("--minify"),
1416
});
1517

packages/tests-e2e/tests/appRouter/isr.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,25 @@ test("Incremental Static Regeneration", async ({ page }) => {
4040

4141
expect(newTime).not.toEqual(finalTime);
4242
});
43+
44+
test("headers", async ({ page }) => {
45+
let responsePromise = page.waitForResponse((response) => {
46+
return response.status() === 200;
47+
});
48+
await page.goto("/isr");
49+
50+
while (true) {
51+
const response = await responsePromise;
52+
const headers = response.headers();
53+
54+
// this was set in middleware
55+
if (headers["cache-control"] === "max-age=10, stale-while-revalidate=999") {
56+
break;
57+
}
58+
await wait(1000);
59+
responsePromise = page.waitForResponse((response) => {
60+
return response.status() === 200;
61+
});
62+
await page.reload();
63+
}
64+
});

0 commit comments

Comments
 (0)