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) {