diff --git a/.gitignore b/.gitignore
index a36d0955..3e40daae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
npm-debug.log
node_modules
!test/fixtures/**/node_modules
+**/.netlify/edge-functions-serve
/core
.eslintcache
.npmrc
diff --git a/node/bundler.test.ts b/node/bundler.test.ts
index 8e6347be..1156501f 100644
--- a/node/bundler.test.ts
+++ b/node/bundler.test.ts
@@ -472,6 +472,44 @@ test('Loads npm modules from bare specifiers', async () => {
await rm(vendorDirectory.path, { force: true, recursive: true })
})
+test('Loads npm modules in a monorepo setup', async () => {
+ const systemLogger = vi.fn()
+ const { basePath: rootPath, cleanup, distPath } = await useFixture('monorepo_npm_module')
+ const basePath = join(rootPath, 'packages', 'frontend')
+ const sourceDirectory = join(basePath, 'functions')
+ const declarations: Declaration[] = [
+ {
+ function: 'func1',
+ path: '/func1',
+ },
+ ]
+ const vendorDirectory = await tmp.dir()
+
+ await bundle([sourceDirectory], distPath, declarations, {
+ basePath,
+ importMapPaths: [join(basePath, 'import_map.json')],
+ rootPath,
+ vendorDirectory: vendorDirectory.path,
+ systemLogger,
+ })
+
+ expect(
+ systemLogger.mock.calls.find((call) => call[0] === 'Could not track dependencies in edge function:'),
+ ).toBeUndefined()
+
+ const manifestFile = await readFile(resolve(distPath, 'manifest.json'), 'utf8')
+ const manifest = JSON.parse(manifestFile)
+ const bundlePath = join(distPath, manifest.bundles[0].asset)
+ const { func1 } = await runESZIP(bundlePath, vendorDirectory.path)
+
+ expect(func1).toBe(
+ `JavaScript, APIs${process.cwd()}, Markup${process.cwd()}`,
+ )
+
+ await cleanup()
+ await rm(vendorDirectory.path, { force: true, recursive: true })
+})
+
test('Loads JSON modules', async () => {
const { basePath, cleanup, distPath } = await useFixture('imports_json')
const sourceDirectory = join(basePath, 'functions')
diff --git a/node/bundler.ts b/node/bundler.ts
index 6a75d521..cc8dc653 100644
--- a/node/bundler.ts
+++ b/node/bundler.ts
@@ -33,6 +33,7 @@ export interface BundleOptions {
internalSrcFolder?: string
onAfterDownload?: OnAfterDownloadHook
onBeforeDownload?: OnBeforeDownloadHook
+ rootPath?: string
systemLogger?: LogFunction
userLogger?: LogFunction
vendorDirectory?: string
@@ -53,6 +54,7 @@ export const bundle = async (
internalSrcFolder,
onAfterDownload,
onBeforeDownload,
+ rootPath,
userLogger,
systemLogger,
vendorDirectory,
@@ -105,6 +107,7 @@ export const bundle = async (
functions,
importMap,
logger,
+ rootPath: rootPath ?? basePath,
vendorDirectory,
})
@@ -250,6 +253,7 @@ interface VendorNPMOptions {
functions: EdgeFunction[]
importMap: ImportMap
logger: Logger
+ rootPath: string
vendorDirectory: string | undefined
}
@@ -258,6 +262,7 @@ const safelyVendorNPMSpecifiers = async ({
functions,
importMap,
logger,
+ rootPath,
vendorDirectory,
}: VendorNPMOptions) => {
try {
@@ -268,6 +273,7 @@ const safelyVendorNPMSpecifiers = async ({
importMap,
logger,
referenceTypes: false,
+ rootPath,
})
} catch (error) {
logger.system(error)
diff --git a/node/npm_dependencies.ts b/node/npm_dependencies.ts
index 419b3389..b69a0b2b 100644
--- a/node/npm_dependencies.ts
+++ b/node/npm_dependencies.ts
@@ -12,6 +12,7 @@ import tmp from 'tmp-promise'
import { ImportMap } from './import_map.js'
import { Logger } from './logger.js'
+import { pathsBetween } from './utils/fs.js'
const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.cts', '.mts'])
@@ -89,24 +90,29 @@ const banner = {
`,
}
+interface GetNPMSpecifiersOptions {
+ basePath: string
+ functions: string[]
+ importMap: ParsedImportMap
+ referenceTypes: boolean
+ rootPath: string
+}
+
/**
* Parses a set of functions and returns a list of specifiers that correspond
* to npm modules.
- *
- * @param basePath Root of the project
- * @param functions Functions to parse
- * @param importMap Import map to apply when resolving imports
- * @param referenceTypes Whether to detect typescript declarations and reference them in the output
*/
-const getNPMSpecifiers = async (
- basePath: string,
- functions: string[],
- importMap: ParsedImportMap,
- referenceTypes: boolean,
-) => {
+const getNPMSpecifiers = async ({
+ basePath,
+ functions,
+ importMap,
+ referenceTypes,
+ rootPath,
+}: GetNPMSpecifiersOptions) => {
const baseURL = pathToFileURL(basePath)
const { reasons } = await nodeFileTrace(functions, {
- base: basePath,
+ base: rootPath,
+ processCwd: basePath,
readFile: async (filePath: string) => {
// If this is a TypeScript file, we need to compile in before we can
// parse it.
@@ -203,6 +209,7 @@ interface VendorNPMSpecifiersOptions {
importMap: ImportMap
logger: Logger
referenceTypes: boolean
+ rootPath?: string
}
export const vendorNPMSpecifiers = async ({
@@ -211,24 +218,26 @@ export const vendorNPMSpecifiers = async ({
functions,
importMap,
referenceTypes,
+ rootPath = basePath,
}: VendorNPMSpecifiersOptions) => {
// The directories that esbuild will use when resolving Node modules. We must
// set these manually because esbuild will be operating from a temporary
// directory that will not live inside the project root, so the normal
// resolution logic won't work.
- const nodePaths = [path.join(basePath, 'node_modules')]
+ const nodePaths = pathsBetween(basePath, rootPath).map((directory) => path.join(directory, 'node_modules'))
// We need to create some files on disk, which we don't want to write to the
// project directory. If a custom directory has been specified, we use it.
// Otherwise, create a random temporary directory.
const temporaryDirectory = directory ? { path: directory } : await tmp.dir()
- const { npmSpecifiers, npmSpecifiersWithExtraneousFiles } = await getNPMSpecifiers(
+ const { npmSpecifiers, npmSpecifiersWithExtraneousFiles } = await getNPMSpecifiers({
basePath,
functions,
- importMap.getContentsWithURLObjects(),
+ importMap: importMap.getContentsWithURLObjects(),
referenceTypes,
- )
+ rootPath,
+ })
// If we found no specifiers, there's nothing left to do here.
if (Object.keys(npmSpecifiers).length === 0) {
diff --git a/node/server/server.test.ts b/node/server/server.test.ts
index 5367f034..cfafc2ab 100644
--- a/node/server/server.test.ts
+++ b/node/server/server.test.ts
@@ -1,5 +1,6 @@
import { readFile } from 'fs/promises'
import { join } from 'path'
+import process from 'process'
import getPort from 'get-port'
import fetch from 'node-fetch'
@@ -105,3 +106,65 @@ test('Starts a server and serves requests for edge functions', async () => {
`/// `,
)
})
+
+test('Serves edge functions in a monorepo setup', async () => {
+ const rootPath = join(fixturesDir, 'monorepo_npm_module')
+ const basePath = join(rootPath, 'packages', 'frontend')
+ const paths = {
+ user: join(basePath, 'functions'),
+ }
+ const port = await getPort()
+ const importMapPaths = [join(basePath, 'import_map.json')]
+ const servePath = join(basePath, '.netlify', 'edge-functions-serve')
+ const server = await serve({
+ basePath,
+ bootstrapURL: 'https://edge.netlify.com/bootstrap/index-combined.ts',
+ importMapPaths,
+ port,
+ rootPath,
+ servePath,
+ })
+
+ const functions = [
+ {
+ name: 'func1',
+ path: join(paths.user, 'func1.ts'),
+ },
+ ]
+ const options = {
+ getFunctionsConfig: true,
+ }
+
+ const { features, functionsConfig, graph, success, npmSpecifiersWithExtraneousFiles } = await server(
+ functions,
+ {
+ very_secret_secret: 'i love netlify',
+ },
+ options,
+ )
+ expect(features).toEqual({ npmModules: true })
+ expect(success).toBe(true)
+ expect(functionsConfig).toEqual([{ path: '/func1' }])
+ expect(npmSpecifiersWithExtraneousFiles).toEqual(['child-1'])
+
+ for (const key in functions) {
+ const graphEntry = graph?.modules.some(
+ // @ts-expect-error TODO: Module graph is currently not typed
+ ({ kind, mediaType, local }) => kind === 'esm' && mediaType === 'TypeScript' && local === functions[key].path,
+ )
+
+ expect(graphEntry).toBe(true)
+ }
+
+ const response1 = await fetch(`http://0.0.0.0:${port}/func1`, {
+ headers: {
+ 'x-nf-edge-functions': 'func1',
+ 'x-ef-passthrough': 'passthrough',
+ 'X-NF-Request-ID': uuidv4(),
+ },
+ })
+ expect(response1.status).toBe(200)
+ expect(await response1.text()).toBe(
+ `JavaScript, APIs${process.cwd()}, Markup${process.cwd()}`,
+ )
+})
diff --git a/node/server/server.ts b/node/server/server.ts
index b036037f..0e103b4f 100644
--- a/node/server/server.ts
+++ b/node/server/server.ts
@@ -29,6 +29,7 @@ interface PrepareServerOptions {
importMap: ImportMap
logger: Logger
port: number
+ rootPath?: string
}
interface StartServerOptions {
@@ -58,6 +59,7 @@ const prepareServer = ({
importMap: baseImportMap,
logger,
port,
+ rootPath,
}: PrepareServerOptions) => {
const processRef: ProcessRef = {}
const startServer = async (
@@ -94,6 +96,7 @@ const prepareServer = ({
importMap,
logger,
referenceTypes: true,
+ rootPath,
})
if (vendor) {
@@ -179,27 +182,106 @@ interface ServeOptions {
formatExportTypeError?: FormatFunction
formatImportError?: FormatFunction
port: number
+ rootPath?: string
servePath: string
userLogger?: LogFunction
systemLogger?: LogFunction
}
export const serve = async ({
+ /**
+ * Path that is common to all functions. Works as the root directory in the
+ * generated bundle.
+ */
basePath,
+
+ /**
+ * URL of the bootstrap layer to use.
+ */
bootstrapURL,
+
+ /**
+ * Path to an SSL certificate to run the Deno server with.
+ */
certificatePath,
+
+ /**
+ * Whether to print verbose information about the server process.
+ */
debug,
+
+ /**
+ * Path of an import map file to be generated using the built-in specifiers
+ * and any npm modules found during the bundling process.
+ */
distImportMapPath,
+
+ /**
+ * Debug settings to use with Deno's `--inspect` and `--inspect-brk` flags.
+ */
inspectSettings,
+
+ /**
+ * Map of feature flags.
+ */
featureFlags,
+
+ /**
+ * Callback function to be triggered whenever a function has a default export
+ * with the wrong type.
+ */
formatExportTypeError,
+
+ /**
+ * Callback function to be triggered whenever an error occurs while importing
+ * a function.
+ */
formatImportError,
+
+ /**
+ * Paths to any additional import map files.
+ */
importMapPaths = [],
+
+ /**
+ * Callback function to be triggered after the Deno CLI has been downloaded.
+ */
onAfterDownload,
+
+ /**
+ * Callback function to be triggered before we attempt to download the Deno
+ * CLI.
+ */
onBeforeDownload,
+
+ /**
+ * Port where the server should listen on.
+ */
port,
+
+ /**
+ * Root path of the project. Defines a boundary outside of which files or npm
+ * modules cannot be included from. This is usually the same as `basePath`,
+ * with monorepos being the main exception, where `basePath` maps to the
+ * package path and `rootPath` is the repository root.
+ */
+ rootPath,
+
+ /**
+ * Path to write ephemeral files that need to be generated for the server to
+ * operate.
+ */
servePath,
+
+ /**
+ * Custom logging function to be used for user-facing messages. Defaults to
+ * `console.log`.
+ */
userLogger,
+
+ /**
+ * Custom logging function to be used for system-level messages.
+ */
systemLogger,
}: ServeOptions) => {
const logger = getLogger(systemLogger, userLogger, debug)
@@ -253,6 +335,7 @@ export const serve = async ({
importMap,
logger,
port,
+ rootPath,
})
return server
diff --git a/node/utils/fs.ts b/node/utils/fs.ts
new file mode 100644
index 00000000..afff29d4
--- /dev/null
+++ b/node/utils/fs.ts
@@ -0,0 +1,15 @@
+import path from 'path'
+
+/**
+ * Returns all the directories obtained by traversing `inner` and its parents
+ * all the way to `outer`, inclusive.
+ */
+export const pathsBetween = (inner: string, outer: string, paths: string[] = []): string[] => {
+ const parent = path.dirname(inner)
+
+ if (inner === outer || inner === parent) {
+ return [...paths, outer]
+ }
+
+ return [inner, ...pathsBetween(parent, outer)]
+}
diff --git a/test/fixtures/monorepo_npm_module/node_modules/child-1/files/file1.txt b/test/fixtures/monorepo_npm_module/node_modules/child-1/files/file1.txt
new file mode 100644
index 00000000..6dcd8e41
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/child-1/files/file1.txt
@@ -0,0 +1 @@
+One
\ No newline at end of file
diff --git a/test/fixtures/monorepo_npm_module/node_modules/child-1/files/file2.txt b/test/fixtures/monorepo_npm_module/node_modules/child-1/files/file2.txt
new file mode 100644
index 00000000..5673baa5
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/child-1/files/file2.txt
@@ -0,0 +1 @@
+Two
\ No newline at end of file
diff --git a/test/fixtures/monorepo_npm_module/node_modules/child-1/index.js b/test/fixtures/monorepo_npm_module/node_modules/child-1/index.js
new file mode 100644
index 00000000..35544be1
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/child-1/index.js
@@ -0,0 +1,15 @@
+import { readFileSync } from "fs"
+import { join } from "path"
+
+export default (input) => {
+ try {
+ const filePath = input === "one" ? 'file1.txt' : 'file2.txt'
+ const fileContents = readFileSync(join(__dirname, "files", filePath))
+
+ console.log(fileContents)
+ } catch {
+ // no-op
+ }
+
+ return `${input}`
+}
\ No newline at end of file
diff --git a/test/fixtures/monorepo_npm_module/node_modules/child-1/package.json b/test/fixtures/monorepo_npm_module/node_modules/child-1/package.json
new file mode 100644
index 00000000..4b3a6414
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/child-1/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "child-1",
+ "version": "1.0.0",
+ "main": "index.js"
+}
\ No newline at end of file
diff --git a/test/fixtures/monorepo_npm_module/node_modules/child-2/index.js b/test/fixtures/monorepo_npm_module/node_modules/child-2/index.js
new file mode 100644
index 00000000..8505c750
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/child-2/index.js
@@ -0,0 +1,3 @@
+import grandchild1 from "grandchild-1"
+
+export default (input) => `${grandchild1(input)}`
\ No newline at end of file
diff --git a/test/fixtures/monorepo_npm_module/node_modules/child-2/package.json b/test/fixtures/monorepo_npm_module/node_modules/child-2/package.json
new file mode 100644
index 00000000..1e4b084c
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/child-2/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "child-2",
+ "version": "1.0.0",
+ "main": "index.js",
+ "dependencies": {
+ "grandchild-1": "1.0.0"
+ }
+}
\ No newline at end of file
diff --git a/test/fixtures/monorepo_npm_module/node_modules/grandchild-1/index.js b/test/fixtures/monorepo_npm_module/node_modules/grandchild-1/index.js
new file mode 100644
index 00000000..59c1e941
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/grandchild-1/index.js
@@ -0,0 +1,3 @@
+import { cwd } from "process"
+
+export default (input) => `${input}${cwd()}`
\ No newline at end of file
diff --git a/test/fixtures/monorepo_npm_module/node_modules/grandchild-1/package.json b/test/fixtures/monorepo_npm_module/node_modules/grandchild-1/package.json
new file mode 100644
index 00000000..bb88071d
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/grandchild-1/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "grandchild-1",
+ "version": "1.0.0",
+ "main": "index.js"
+}
\ No newline at end of file
diff --git a/test/fixtures/monorepo_npm_module/node_modules/parent-1/index.js b/test/fixtures/monorepo_npm_module/node_modules/parent-1/index.js
new file mode 100644
index 00000000..c3bcbb5e
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/parent-1/index.js
@@ -0,0 +1,3 @@
+import child1 from "child-1"
+
+export default (input) => `${child1(input)}`
\ No newline at end of file
diff --git a/test/fixtures/monorepo_npm_module/node_modules/parent-1/package.json b/test/fixtures/monorepo_npm_module/node_modules/parent-1/package.json
new file mode 100644
index 00000000..b18a38c6
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/parent-1/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "parent-1",
+ "version": "1.0.0",
+ "main": "index.js",
+ "dependencies": {
+ "child-1": "1.0.0"
+ }
+}
\ No newline at end of file
diff --git a/test/fixtures/monorepo_npm_module/node_modules/parent-2/index.js b/test/fixtures/monorepo_npm_module/node_modules/parent-2/index.js
new file mode 100644
index 00000000..984b0b18
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/parent-2/index.js
@@ -0,0 +1,4 @@
+import child2 from "child-2"
+
+export default (input) => `${child2(input)}`
+
diff --git a/test/fixtures/monorepo_npm_module/node_modules/parent-2/package.json b/test/fixtures/monorepo_npm_module/node_modules/parent-2/package.json
new file mode 100644
index 00000000..adb0cc33
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/parent-2/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "parent-2",
+ "version": "1.0.0",
+ "main": "index.js"
+}
\ No newline at end of file
diff --git a/test/fixtures/monorepo_npm_module/node_modules/parent-3/index.js b/test/fixtures/monorepo_npm_module/node_modules/parent-3/index.js
new file mode 100644
index 00000000..f447613f
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/parent-3/index.js
@@ -0,0 +1,4 @@
+import child2 from "child-2"
+
+export default (input) => `${child2(input)}`
+
diff --git a/test/fixtures/monorepo_npm_module/node_modules/parent-3/package.json b/test/fixtures/monorepo_npm_module/node_modules/parent-3/package.json
new file mode 100644
index 00000000..82b794a5
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/node_modules/parent-3/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "parent-3",
+ "version": "1.0.0",
+ "main": "index.js",
+ "dependencies": {
+ "child-2": "1.0.0"
+ }
+}
\ No newline at end of file
diff --git a/test/fixtures/monorepo_npm_module/package.json b/test/fixtures/monorepo_npm_module/package.json
new file mode 100644
index 00000000..3dbc1ca5
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "module"
+}
diff --git a/test/fixtures/monorepo_npm_module/packages/frontend/functions/func1.ts b/test/fixtures/monorepo_npm_module/packages/frontend/functions/func1.ts
new file mode 100644
index 00000000..98e72e81
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/packages/frontend/functions/func1.ts
@@ -0,0 +1,18 @@
+import parent1 from 'parent-1'
+import parent3 from './lib/util.ts'
+import { echo, parent2 } from 'alias:helper'
+import { HTMLRewriter } from 'html-rewriter'
+
+await Promise.resolve()
+
+new HTMLRewriter()
+
+export default async () => {
+ const text = [parent1('JavaScript'), parent2('APIs'), parent3('Markup')].join(', ')
+
+ return new Response(echo(text))
+}
+
+export const config = {
+ path: '/func1',
+}
diff --git a/test/fixtures/monorepo_npm_module/packages/frontend/functions/lib/util.ts b/test/fixtures/monorepo_npm_module/packages/frontend/functions/lib/util.ts
new file mode 100644
index 00000000..4ff7cd8a
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/packages/frontend/functions/lib/util.ts
@@ -0,0 +1,3 @@
+import parent3 from 'parent-3'
+
+export default parent3
diff --git a/test/fixtures/monorepo_npm_module/packages/frontend/helper.ts b/test/fixtures/monorepo_npm_module/packages/frontend/helper.ts
new file mode 100644
index 00000000..c302d8dd
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/packages/frontend/helper.ts
@@ -0,0 +1,6 @@
+import parent2 from 'parent-2'
+
+export const greet = (name: string) => `Hello, ${name}!`
+export const echo = (name: string) => name
+export const yell = (message: string) => message.toUpperCase()
+export { parent2 }
diff --git a/test/fixtures/monorepo_npm_module/packages/frontend/import_map.json b/test/fixtures/monorepo_npm_module/packages/frontend/import_map.json
new file mode 100644
index 00000000..5ef2121b
--- /dev/null
+++ b/test/fixtures/monorepo_npm_module/packages/frontend/import_map.json
@@ -0,0 +1,6 @@
+{
+ "imports": {
+ "alias:helper": "./helper.ts",
+ "html-rewriter": "https://ghuc.cc/worker-tools/html-rewriter/index.ts"
+ }
+}
diff --git a/test/integration/test.js b/test/integration/test.js
index 0439adfe..cce7a87c 100644
--- a/test/integration/test.js
+++ b/test/integration/test.js
@@ -97,6 +97,10 @@ const runAssertions = ({ basePath, bundleOutput }) => {
}
const cleanup = async () => {
+ if (process.env.CI) {
+ return
+ }
+
console.log(`Cleaning up temporary files...`)
for (const folder of pathsToCleanup) {