Skip to content

Commit eeefffe

Browse files
authored
Add full monorepo support for npm, yarn, and pnpm (#23)
1 parent 203a94c commit eeefffe

File tree

3 files changed

+130
-95
lines changed

3 files changed

+130
-95
lines changed

README.md

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ OpenNext aims to support all Next.js 13 features. Some features are work in prog
6363

6464
If your Next.js app does not use [middleware](https://nextjs.org/docs/advanced-features/middleware), `middleware-function` will not be generated.
6565

66+
3. Add `.open-next` to your `.gitignore` file
67+
```
68+
# OpenNext
69+
/.open-next/
70+
```
71+
6672
## How does OpenNext work?
6773

6874
When calling `open-next build`, OpenNext **builds the Next.js app** using the `@vercel/next` package. And then it **transforms the build output** to a format that can be deployed to AWS.
@@ -111,17 +117,55 @@ public,max-age=0,s-maxage=31536000,must-revalidate
111117

112118
#### Image optimization function
113119

114-
Create a Lambda function with the code from `.open-next/image-optimization-function`.
120+
Create a Lambda function with the code from `.open-next/image-optimization-function`, and the handler is `index.mjs`.
115121

116122
This function handles image optimization requests when the Next.js `<Image>` component is used. The [sharp](https://www.npmjs.com/package/sharp) library is bundled with the function. And it is used to convert the image.
117123

118124
Note that image optimization function responds with the `Cache-Control` header, and the image will be cached both at the CDN level and at the browser level.
119125

120126
#### Server Lambda function
121127

122-
Create a Lambda function with the code from `.open-next/server-function`.
128+
Create a Lambda function with the code from `.open-next/server-function`, and the handler is `index.mjs`.
129+
130+
This function handles all the other types of requests from the Next.js app, including Server-side Rendering (SSR) requests and API requests. OpenNext builds the Next.js app in the **standalone** mode. The standalone mode generates a `.next` folder containing the **NextServer** class that does the request handling. It also generates a `node_modules` folder with **all the dependencies** required to run the `NextServer`.
131+
132+
```
133+
.next/ -> NextServer
134+
node_modules/ -> dependencies
135+
```
136+
137+
The server function adapter wraps around `NextServer` and exports a handler function that supports the Lambda request and response. The `server-function` bundle looks like:
138+
139+
```diff
140+
.next/ -> NextServer
141+
node_modules/ -> dependencies
142+
+ index.mjs -> server function adapter
143+
```
144+
145+
**Monorepo**
146+
The build output looks slightly different when the Next.js app is part of a monorepo. Imagine the app sits inside `packages/web`, the build output looks like:
147+
148+
```
149+
packages/
150+
web/
151+
.next/ -> NextServer
152+
node_modules/ -> dependencies from root node_modules (optional)
153+
node_modules/ -> dependencies from package node_modules
154+
```
123155

124-
This function handles all the other types of requests from the Next.js app, including Server-side Rendering (SSR) requests and API requests. OpenNext builds the Next.js app in the **standalone** mode. The standalone mode generates a **NextServer** class that does the request handling. And the server function wraps around the NextServer.
156+
In this case, the server function adapter needs to be created inside `packages/web` next to `.next/`. This is to ensure the adapter can import dependencies from both `node_modules` folders. We could set the Lambda handler to point to `packages/web/index.mjs`, but it is a bad practice to have the Lambda configuration coupled with the project structure. Instead, we will add a wrapper `index.mjs` at the `server-function` bundle root that re-exports the adapter.
157+
158+
```diff
159+
packages/
160+
web/
161+
.next/ -> NextServer
162+
node_modules/ -> dependencies from root node_modules (optional)
163+
+ index.mjs -> server function adapter
164+
node_modules/ -> dependencies from package node_modules
165+
+ index.mjs -> adapter wrapper
166+
```
167+
168+
This ensure the Lambda handler remains at `index.mjs`.
125169

126170
#### CloudFront distribution
127171

@@ -137,7 +181,7 @@ Create a CloudFront distribution, and dispatch requests to their cooresponding h
137181

138182
#### Middleware Lambda@Edge function (optional)
139183

140-
Create a Lambda function with the code from `.open-next/middleware-function`, and attach it to the `/_next/data/*` and `/*` behaviors as `viewer request` edge function. This allows the function to run your [Middleware](https://nextjs.org/docs/advanced-features/middleware) code before the request hits your server function, and also before cached content.
184+
Create a Lambda function with the code from `.open-next/middleware-function`, and the handler is `index.mjs`. Attach it to the `/_next/data/*` and `/*` behaviors as `viewer request` edge function. This allows the function to run your [Middleware](https://nextjs.org/docs/advanced-features/middleware) code before the request hits your server function, and also before cached content.
141185

142186
The middleware function uses the Node.js 18 [global fetch API](https://nodejs.org/de/blog/announcements/v18-release-announce/#new-globally-available-browser-compatible-apis). It requires to run on Node.js 18 runtime. [See why Node.js 18 runtime is required.](#workaround-add-headersgetall-extension-to-the-middleware-function)
143187

@@ -169,6 +213,55 @@ To workaround the issue, the server function checks if the request is to an HTML
169213
public, max-age=0, s-maxage=31536000, must-revalidate
170214
```
171215

216+
#### WORKAROUND: Set `NextServer` working directory (AWS specific)
217+
218+
Next.js recommends using `process.cwd()` instead of `__dirname` to get the app directory. Imagine you have a `posts` folder in your app with markdown files:
219+
220+
```
221+
pages/
222+
posts/
223+
my-post.md
224+
public/
225+
next.config.js
226+
package.json
227+
```
228+
229+
And you can build the file path like this:
230+
231+
```ts
232+
path.join(process.cwd(), "posts", "my-post.md");
233+
```
234+
235+
Recall in the [Server function](#server-lambda-function) section. In a non-monorepo setup, the `server-function` bundle looks like:
236+
237+
```
238+
.next/
239+
node_modules/
240+
posts/
241+
my-post.md <- path is "posts/my-post.md"
242+
index.mjs
243+
```
244+
245+
And `path.join(process.cwd(), "posts", "my-post.md")` resolves to the correct path.
246+
247+
However, when the user's app is inside a monorepo (ie. at `/packages/web`), the `server-function` bundle looks like:
248+
249+
```
250+
packages/
251+
web/
252+
.next/
253+
node_modules/
254+
posts/
255+
my-post.md <- path is "packages/web/posts/my-post.md"
256+
index.mjs
257+
node_modules/
258+
index.mjs
259+
```
260+
261+
And `path.join(process.cwd(), "posts", "my-post.md")` cannot be resolved.
262+
263+
To workaround the issue, we change the working directory for the server function to where `.next/` is located, ie. `packages/web`.
264+
172265
#### WORKAROUND: Pass headers from middleware function to server function (AWS specific)
173266

174267
[Middleware](https://nextjs.org/docs/advanced-features/middleware) allows you to modify the request and response headers. This requires the middleware function to be able to pass custom headers defined in your Next.js app's middleware code to the server function.
@@ -210,22 +303,22 @@ To run `OpenNext` locally:
210303
1. Build `open-next`
211304
```bash
212305
cd open-next
213-
yarn build
306+
pnpm build
214307
```
215308
1. Run `open-next` in watch mode
216309
```bash
217-
yarn dev
310+
pnpm dev
218311
```
219312
1. Make `open-next` linkable from your Next.js app
220313
```bash
221-
yarn link
314+
pnpm link --global
222315
```
223316
1. Link `open-next` in your Next.js app
224317
```bash
225318
cd path/to/my/nextjs/app
226-
yarn link open-next
319+
pnpm link --global open-next
227320
```
228-
Now you can make changes in `open-next`, and run `yarn open-next build` in your Next.js app to test the changes.
321+
Now you can make changes in `open-next`, and run `pnpm open-next build` in your Next.js app to test the changes.
229322

230323
## FAQ
231324

src/adapters/server-adapter.ts

Lines changed: 7 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
import NextServer from "next/dist/server/next-server.js";
1212
import { loadConfig } from "./util.js"
1313

14+
setNextjsServerWorkingDirectory();
1415
const nextDir = path.join(__dirname, ".next");
1516
const config = loadConfig(nextDir);
1617
const htmlPages = loadHtmlPages();
@@ -49,8 +50,6 @@ const server = slsHttp(
4950
{
5051
binary: true,
5152
provider: "aws",
52-
// TODO: add support for basePath
53-
//basePath: process.env.NEXTJS_LAMBDA_BASE_PATH,
5453
},
5554
);
5655

@@ -90,76 +89,15 @@ export async function handler(event: APIGatewayProxyEventV2, context: Context):
9089
// Helper functions //
9190
//////////////////////
9291

92+
function setNextjsServerWorkingDirectory() {
93+
// WORKAROUND: Set `NextServer` working directory (AWS specific) — https://github.com/serverless-stack/open-next#workaround-set-nextserver-working-directory-aws-specific
94+
process.chdir(__dirname);
95+
}
96+
9397
function loadHtmlPages() {
9498
const filePath = path.join(nextDir, "server", "pages-manifest.json");
9599
const json = fs.readFileSync(filePath, "utf-8");
96100
return Object.entries(JSON.parse(json))
97101
.filter(([_, value]) => (value as string).endsWith(".html"))
98102
.map(([key]) => key);
99-
}
100-
101-
//const createApigHandler = () => {
102-
// const config = loadConfig();
103-
// const requestHandler = new NextServer(config).getRequestHandler();
104-
//
105-
// return async (event) => {
106-
// const request = convertApigRequestToNext(event);
107-
// const response = await requestHandler(request);
108-
// return convertNextResponseToApig(response);
109-
// };
110-
//};
111-
//
112-
//export const handler = createApigHandler();
113-
114-
//function convertApigRequestToNext(event) {
115-
// let host = event.headers["x-forwarded-host"] || event.headers.host;
116-
// let search = event.rawQueryString.length ? `?${event.rawQueryString}` : "";
117-
// let scheme = "https";
118-
// let url = new URL(event.rawPath + search, `${scheme}://${host}`);
119-
// let isFormData = event.headers["content-type"]?.includes(
120-
// "multipart/form-data"
121-
// );
122-
//
123-
// // Build headers
124-
// const headers = new Headers();
125-
// for (let [header, value] of Object.entries(event.headers)) {
126-
// if (value) {
127-
// headers.append(header, value);
128-
// }
129-
// }
130-
//
131-
// return new Request(url.href, {
132-
// method: event.requestContext.http.method,
133-
// headers,
134-
// body:
135-
// event.body && event.isBase64Encoded
136-
// ? isFormData
137-
// ? Buffer.from(event.body, "base64")
138-
// : Buffer.from(event.body, "base64").toString()
139-
// : event.body,
140-
// });
141-
//}
142-
//
143-
//async function convertNextResponseToApig(response) {
144-
// // Build cookies
145-
// // note: AWS API Gateway will send back set-cookies outside of response headers.
146-
// const cookies = [];
147-
// for (let [key, values] of Object.entries(response.headers.raw())) {
148-
// if (key.toLowerCase() === "set-cookie") {
149-
// for (let value of values) {
150-
// cookies.push(value);
151-
// }
152-
// }
153-
// }
154-
//
155-
// if (cookies.length) {
156-
// response.headers.delete("Set-Cookie");
157-
// }
158-
//
159-
// return {
160-
// statusCode: response.status,
161-
// headers: Object.fromEntries(response.headers.entries()),
162-
// cookies,
163-
// body: await response.text(),
164-
// };
165-
//}
103+
}

src/build.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@ const outputDir = ".open-next";
1111
const tempDir = path.join(outputDir, ".build");
1212

1313
export async function build() {
14-
printVersion();
15-
1614
// Pre-build validation
17-
printHeader("Validating Next.js app");
15+
printVersion();
1816
checkRunningInsideNextjsApp();
1917
setStandaloneBuildMode();
2018
const monorepoRoot = findMonorepoRoot();
@@ -46,7 +44,7 @@ function findMonorepoRoot() {
4644
|| fs.existsSync(path.join(currentPath, "yarn.lock"))
4745
|| fs.existsSync(path.join(currentPath, "pnpm-lock.yaml"))) {
4846
if (currentPath !== appPath) {
49-
console.info("Monorepo root detected at", currentPath);
47+
console.info("Monorepo detected at", currentPath);
5048
}
5149
return currentPath;
5250
}
@@ -78,9 +76,11 @@ function buildNextjsApp(monorepoRoot: string) {
7876
function printHeader(header: string) {
7977
header = `OpenNext — ${header}`;
8078
console.info([
79+
"",
8180
"┌" + "─".repeat(header.length + 2) + "┐",
8281
`│ ${header} │`,
8382
"└" + "─".repeat(header.length + 2) + "┘",
83+
"",
8484
].join("\n"));
8585
}
8686

@@ -116,25 +116,19 @@ function createServerBundle(monorepoRoot: string) {
116116
path.join(outputPath),
117117
{ recursive: true, verbatimSymlinks: true }
118118
);
119+
120+
// Resolve path to the Next.js app if inside the monorepo
119121
// note: if user's app is inside a monorepo, standalone mode places
120122
// `node_modules` inside `.next/standalone`, and others inside
121123
// `.next/standalone/package/path` (ie. `.next`, `server.js`).
122-
// We need to move them to the root of the output folder.
123-
if (monorepoRoot) {
124-
const packagePath = path.relative(monorepoRoot, appPath);
125-
fs.readdirSync(path.join(outputPath, packagePath))
126-
.forEach(file => {
127-
fs.renameSync(
128-
path.join(outputPath, packagePath, file),
129-
path.join(outputPath, file)
130-
);
131-
});
132-
}
124+
// We need to output the handler file inside the package path.
125+
const isMonorepo = monorepoRoot !== appPath;
126+
const packagePath = path.relative(monorepoRoot, appPath);
133127

134128
// Standalone output already has a Node server "server.js", remove it.
135129
// It will be replaced with the Lambda handler.
136130
fs.rmSync(
137-
path.join(outputPath, "server.js"),
131+
path.join(outputPath, packagePath, "server.js"),
138132
{ force: true }
139133
);
140134

@@ -145,7 +139,7 @@ function createServerBundle(monorepoRoot: string) {
145139
esbuildSync({
146140
entryPoints: [path.join(__dirname, "adapters", "server-adapter.js")],
147141
external: ["next"],
148-
outfile: path.join(outputPath, "index.mjs"),
142+
outfile: path.join(outputPath, packagePath, "index.mjs"),
149143
banner: {
150144
js: [
151145
"import { createRequire as topLevelCreateRequire } from 'module';",
@@ -155,6 +149,16 @@ function createServerBundle(monorepoRoot: string) {
155149
].join(""),
156150
},
157151
});
152+
// note: in the monorepo case, the handler file is output to
153+
// `.next/standalone/package/path/index.mjs`, but we want
154+
// the Lambda function to be able to find the handler at
155+
// the root of the bundle. We will create a dummy `index.mjs`
156+
// that re-exports the real handler.
157+
if (isMonorepo) {
158+
fs.writeFileSync(path.join(outputPath, "index.mjs"), [
159+
`export * from "./${packagePath}/index.mjs";`,
160+
].join(""))
161+
};
158162
}
159163

160164
function createImageOptimizationBundle() {

0 commit comments

Comments
 (0)