Skip to content

Commit 1087dcf

Browse files
authored
feat: Merge pull request #63 from seamapi/feat-edge-functions
2 parents 6dbea61 + 8f1054c commit 1087dcf

13 files changed

Lines changed: 600 additions & 328 deletions

File tree

.github/workflows/npm-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
with:
1717
node-version: 18
1818
- name: Run NPM Install
19-
run: npm install
19+
run: yarn install
2020
- name: Typecheck
2121
run: npx tsc
2222
- name: Run NPM Test

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ We then construct a main export file that knows how to parse `next.config.js`
6666
and route to the correct files, which we've statically analyzed and are included
6767
in the generated `dist/index.js` file.
6868

69+
## Caveats
70+
71+
Vercel's Edge Runtime is supported only when running in a Node.js environment. In other words, a bundled nsm project cannot be run in the Edge Runtime.
72+
73+
nsm's implementation of the Edge Runtime does not support:
74+
75+
- [`@vercel/og`](https://www.npmjs.com/package/@vercel/og)
76+
- `import {userAgent} from "next/server"`
77+
6978
## FAQ
7079

7180
### Why can't Next.js bundle into an npm module?

ava.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ module.exports = {
33
extensions: ["ts"],
44
timeout: "2m",
55
require: ["esbuild-runner/register"],
6+
ignoredByWatcher: ["**/.next/**", "**/.nsm/**"],
67
}

nsm/index.ts

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import nextConfig from "./next.config"
1212
import { removePathTrailingSlash } from "./nextjs-middleware/normalize-trailing-slash"
1313
import { getRouteRegex } from "./route-matcher/route-regex"
1414
import type { NextApiHandler } from "./types/nextjs"
15+
import * as esbuild from "esbuild"
16+
import axios from "axios"
17+
import * as edge from "edge-runtime"
18+
import { environmentPlugin as esbuildEnvironmentPlugin } from "esbuild-plugin-environment"
1519

1620
const debug = Debug("nsm")
1721

@@ -84,25 +88,99 @@ export const runServer = async ({
8488
)
8589
debug(`resolved request to "${resolveResult.parsedAs.pathname}"`)
8690

87-
const { serverFunc, match } =
91+
const { pathOrFunction, match, fsPath } =
8892
routeMatcher(resolveResult.parsedAs.pathname) || {}
8993

90-
if (typeof serverFunc === "string") {
94+
if (
95+
typeof pathOrFunction === "string" &&
96+
pathOrFunction.endsWith(".html")
97+
) {
9198
res.statusCode = 200
92-
res.end(await fs.readFile(serverFunc))
99+
res.end(await fs.readFile(pathOrFunction))
93100
return
94101
}
95-
if (!serverFunc) {
102+
if (!pathOrFunction) {
96103
res.statusCode = 404
97104
res.end("404") // TODO use routes 404
98105
return
99106
}
100107

108+
const apiHandler =
109+
typeof pathOrFunction === "string"
110+
? require(pathOrFunction)
111+
: pathOrFunction
112+
113+
if (apiHandler.config?.runtime === "edge") {
114+
const page = resolveResult.asPath
115+
116+
// todo: cache result?
117+
const result = await esbuild.build({
118+
stdin: {
119+
contents: `
120+
import {NextRequest} from "next/dist/server/web/spec-extension/request"
121+
import {NextFetchEvent} from "next/dist/server/web/spec-extension/fetch-event"
122+
123+
import handler from "${pathOrFunction}"
124+
125+
if (typeof handler !== 'function') {
126+
throw new Error('The Edge Function "pages${page}" must export a \`default\` function');
127+
}
128+
129+
addEventListener("fetch", async event => {
130+
const request = new NextRequest(event.request)
131+
const nextEvent = new NextFetchEvent({request, page: "${page}"})
132+
return event.respondWith(await handler(request, nextEvent))
133+
})
134+
`,
135+
resolveDir: __dirname,
136+
loader: "ts",
137+
},
138+
bundle: true,
139+
format: "esm",
140+
banner: {
141+
js: "const process = {env: {}};const require = () => ({})",
142+
},
143+
write: false,
144+
external: [
145+
"next/dist/compiled/@vercel/og/*",
146+
"next/dist/server/web/spec-extension/user-agent*",
147+
],
148+
plugins: [esbuildEnvironmentPlugin(Object.keys(process.env))],
149+
})
150+
151+
const runtime = new edge.EdgeRuntime({
152+
initialCode: result.outputFiles[0].text,
153+
})
154+
const edgeServer = await edge.runServer({ runtime })
155+
const port = new URL(edgeServer.url).port
156+
157+
const response = await axios.request({
158+
baseURL: `http://localhost:${port}`,
159+
url: req.url,
160+
method: req.method,
161+
headers: req.headers,
162+
data: req,
163+
validateStatus: () => true,
164+
responseType: "arraybuffer",
165+
})
166+
167+
for (const header of response.headers) {
168+
res.setHeader(header[0], header[1])
169+
}
170+
171+
res.statusCode = response.status
172+
res.end(Buffer.from(response.data))
173+
174+
await edgeServer.close()
175+
176+
return
177+
}
178+
101179
const wrappedServerFunc = (wrappers as any)(
102-
...[...middlewares, serverFunc?.default || serverFunc],
180+
...[...middlewares, apiHandler?.default || apiHandler],
103181
)
104182

105-
wrappedServerFunc.config = serverFunc.config || {}
183+
wrappedServerFunc.config = apiHandler.config || {}
106184

107185
await apiResolver(
108186
req,

nsm/route-matcher/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ import { getRouteMatcherFunc } from "./get-route-matcher-func"
55
export default (routeMapping: any) => {
66
// convert each route to a regex
77
const routes: any[] = []
8-
for (const [fsPath, serverFunc] of Object.entries(routeMapping)) {
8+
for (const [fsPath, pathOrFunction] of Object.entries(routeMapping)) {
99
const routeRegex = getRouteRegex(fsPath)
1010
routes.push({
1111
fsPath,
1212
routeRegex,
1313
matcherFunc: getRouteMatcherFunc(routeRegex),
14-
serverFunc,
14+
pathOrFunction,
1515
// TODO use whatever priority func nextjs uses
1616
priority: -Object.keys(routeRegex.groups).length,
1717
})
@@ -22,10 +22,10 @@ export default (routeMapping: any) => {
2222
// TODO sort routes to fix precedence
2323

2424
return (incomingPath: string) => {
25-
for (const { serverFunc, matcherFunc, fsPath } of routes) {
25+
for (const { pathOrFunction, matcherFunc, fsPath } of routes) {
2626
const match = matcherFunc(incomingPath)
2727
if (match) {
28-
return { serverFunc, match, fsPath }
28+
return { pathOrFunction, match, fsPath }
2929
}
3030
}
3131
return null

nsm/scripts/generate-routes.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,18 @@ async function generateRouteFile({
110110
const fpNoExt = fp.split(".").slice(0, -1).join(".")
111111
const fpExt = fpNoExt.split(".").slice(-1)[0]
112112
113-
return fp.startsWith("pages/api")
114-
? `"${route}": require("${pagesDirRelativePath}/${fpNoExt}")`
115-
: `"${route}": serveStatic("${fpExt}", require("./generated_static/server/${fp}.ts").default)`
113+
if (fp.startsWith("pages/api")) {
114+
const absolutePath = path.join(
115+
__dirname,
116+
"../",
117+
pagesDirRelativePath,
118+
fpNoExt,
119+
)
120+
121+
return `"${route}": "${absolutePath}"`
122+
} else {
123+
return `"${route}": serveStatic("${fpExt}", require("./generated_static/server/${fp}.ts").default)`
124+
}
116125
})
117126
118127
.join(",")}${staticRoutesFiles}

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,14 @@
5454
"content-type": "^1.0.5",
5555
"cookie": "^0.5.0",
5656
"debug": "^4.3.4",
57-
"esbuild": "^0.17.16",
5857
"esbuild-runner": "^2.2.1",
5958
"escape-string-regexp": "^4.0.0",
6059
"execa": "^5.0.0",
6160
"get-port": "^5.0.0",
6261
"glob": "^10.0.0",
6362
"mime-types": "^2.1.35",
6463
"mkdirp": "^3.0.0",
65-
"next": "^12.0.7",
64+
"next": "^13.5.4",
6665
"nextjs-middleware-wrappers": "^1.3.0",
6766
"path-to-regexp": "^6.2.0",
6867
"prettier": "^3.0.3",
@@ -72,6 +71,9 @@
7271
"typescript": "^4.5.4"
7372
},
7473
"dependencies": {
74+
"edge-runtime": "2.5.4",
75+
"esbuild": "0.19.5",
76+
"esbuild-plugin-environment": "0.2.4",
7577
"find-up": "^5.0.0",
7678
"load-tsconfig": "^0.2.5",
7779
"recursive-copy": "^2.0.13",

tests/assets/nextjs-sample-project/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
},
1313
"dependencies": {
1414
"@types/react": "^18.2.9",
15-
"next": "^13.4.4",
15+
"next": "^13.5.4",
1616
"path-to-regexp": "^6.2.0",
1717
"react": "^18.2.0",
1818
"react-dom": "^18.2.0",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { NextFetchEvent, NextRequest, NextResponse } from "next/server"
2+
3+
export const config = {
4+
runtime: "edge",
5+
}
6+
7+
export default async function handler(
8+
request: NextRequest,
9+
event: NextFetchEvent,
10+
) {
11+
let areNodeBuiltinsAvailable = true
12+
try {
13+
// Need to assign this to a variable instead of `import("node:fs")` so an error isn't thrown during the normal build
14+
const moduleToAttemptToImport = "node:fs"
15+
await import(moduleToAttemptToImport)
16+
} catch {
17+
areNodeBuiltinsAvailable = false
18+
}
19+
20+
return NextResponse.json(
21+
{
22+
isFetchAvailable: typeof fetch === "function",
23+
areNodeBuiltinsAvailable,
24+
requestUrl: request.url,
25+
sourcePageFromEvent: event.sourcePage,
26+
fooBarEnvVar: process.env.FOO_BAR,
27+
},
28+
{
29+
headers: {
30+
"x-custom-header": "foo",
31+
},
32+
status: 201,
33+
},
34+
)
35+
}

tests/assets/nextjs-sample-project/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"compilerOptions": {
3-
"target": "es5",
3+
"target": "es2020",
44
"lib": ["dom", "dom.iterable", "esnext"],
55
"allowJs": true,
66
"skipLibCheck": true,

0 commit comments

Comments
 (0)