diff --git a/.eslintignore b/.eslintignore index cc151edb48..b3d6172497 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,4 +8,5 @@ packages/runtime/lib packages/runtime/dist-types jestSetup.js test/e2e -test/fixtures/broken_next_config/next.config.js \ No newline at end of file +test/fixtures/broken_next_config/next.config.js +test/integration/fixtures diff --git a/.eslintrc.js b/.eslintrc.js index 1510231221..94cbdcf921 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -48,6 +48,9 @@ module.exports = { 'n/no-unsupported-features/es-syntax': 'off', '@typescript-eslint/no-extra-semi': 'off', 'n/no-missing-import': 'off', + // https://github.com/typescript-eslint/typescript-eslint/issues/2483 + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': 'error', }, }, { @@ -74,12 +77,18 @@ module.exports = { 'unicorn/no-await-expression-member': 0, 'import/no-anonymous-default-export': 0, 'no-shadow': 0, + '@typescript-eslint/no-shadow': 0, '@typescript-eslint/no-var-requires': 0, 'require-await': 0, + 'n/no-sync': 0, + 'promise/prefer-await-to-then': 0, + 'no-async-promise-executor': 0, + 'import/no-dynamic-require': 0, // esling-plugin-jest specific rules 'jest/consistent-test-it': ['error', { fn: 'it', withinDescribe: 'it' }], 'jest/no-disabled-tests': 0, 'jest/no-conditional-expect': 0, + 'jest/no-standalone-expect': [2, { additionalTestBlockFunctions: ['beforeAll'] }], }, }, ], diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml new file mode 100644 index 0000000000..9e662d6a67 --- /dev/null +++ b/.github/workflows/test-integration.yml @@ -0,0 +1,29 @@ +name: Next Runtime Integration Tests + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + build: + name: Integration tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Installing with LTS Node.js + uses: actions/setup-node@v2 + with: + node-version: 18 + check-latest: true + - name: Install netlify-cli and npm + run: npm install -g netlify-cli npm + - name: NPM Install + run: npm install + - name: Run integration tests + run: npm run test:integration diff --git a/package-lock.json b/package-lock.json index 8ec7c12319..39ec7f1086 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "jest-junit": "^14.0.1", "mock-fs": "^5.2.0", "netlify-plugin-cypress": "^2.2.1", + "node-fetch": "^2.6.6", "npm-run-all": "^4.1.5", "outdent": "^0.8.0", "pathe": "^1.1.0", @@ -66,7 +67,8 @@ "sass": "^1.49.0", "sharp": "^0.30.4", "tmp-promise": "^3.0.2", - "typescript": "^4.3.4" + "typescript": "^4.3.4", + "wait-on": "^7.0.1" }, "engines": { "node": ">=16.0.0" @@ -2708,6 +2710,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.7", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.7.tgz", @@ -5662,6 +5679,27 @@ "node": ">= 8.0.0" } }, + "node_modules/@sideway/address": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true + }, "node_modules/@sinclair/typebox": { "version": "0.25.24", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", @@ -7124,6 +7162,30 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -16194,6 +16256,19 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "17.9.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.9.2.tgz", + "integrity": "sha512-Itk/r+V4Dx0V3c7RLFdRh12IOjySm2/WGPMubBT92cQvRfYZhPM2W0hZlctjj72iES8jsRCwp7S/cRmWBnJ4nw==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/jose": { "version": "4.13.1", "resolved": "https://registry.npmjs.org/jose/-/jose-4.13.1.tgz", @@ -21375,9 +21450,9 @@ } }, "node_modules/rxjs": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz", - "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz", + "integrity": "sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==", "dev": true, "dependencies": { "tslib": "^2.1.0" @@ -23844,6 +23919,25 @@ "node": ">=10" } }, + "node_modules/wait-on": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.0.1.tgz", + "integrity": "sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==", + "dev": true, + "dependencies": { + "axios": "^0.27.2", + "joi": "^17.7.0", + "lodash": "^4.17.21", + "minimist": "^1.2.7", + "rxjs": "^7.8.0" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/walk-back": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/walk-back/-/walk-back-4.0.0.tgz", @@ -26229,6 +26323,21 @@ } } }, + "@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true + }, + "@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, "@humanwhocodes/config-array": { "version": "0.11.7", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.7.tgz", @@ -28282,6 +28391,27 @@ "string.prototype.codepointat": "^0.2.1" } }, + "@sideway/address": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, + "@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true + }, + "@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true + }, "@sinclair/typebox": { "version": "0.25.24", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", @@ -29421,6 +29551,29 @@ "integrity": "sha512-u2MVsXfew5HBvjsczCv+xlwdNnB1oQR9HlAcsejZttNjKKSkeDNVwB1vMThIUIFI9GoT57Vtk8iQLwqOfAkboA==", "dev": true }, + "axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "dev": true, + "requires": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + }, + "dependencies": { + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + } + } + }, "axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -36263,6 +36416,19 @@ } } }, + "joi": { + "version": "17.9.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.9.2.tgz", + "integrity": "sha512-Itk/r+V4Dx0V3c7RLFdRh12IOjySm2/WGPMubBT92cQvRfYZhPM2W0hZlctjj72iES8jsRCwp7S/cRmWBnJ4nw==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "jose": { "version": "4.13.1", "resolved": "https://registry.npmjs.org/jose/-/jose-4.13.1.tgz", @@ -40203,9 +40369,9 @@ } }, "rxjs": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz", - "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz", + "integrity": "sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==", "dev": true, "requires": { "tslib": "^2.1.0" @@ -42132,6 +42298,19 @@ "xml-name-validator": "^3.0.0" } }, + "wait-on": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.0.1.tgz", + "integrity": "sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==", + "dev": true, + "requires": { + "axios": "^0.27.2", + "joi": "^17.7.0", + "lodash": "^4.17.21", + "minimist": "^1.2.7", + "rxjs": "^7.8.0" + } + }, "walk-back": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/walk-back/-/walk-back-4.0.0.tgz", diff --git a/package.json b/package.json index 4c897c6b23..9c1720c24e 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:next:all": "RUN_SKIPPED_TESTS=1 jest -c test/e2e/jest.config.all.js", "test:next:appdir": "jest -c test/e2e/jest.config.appdir.js", "test:jest": "jest", + "test:integration": "jest -c test/integration/jest.config.js", "playwright:install": "playwright install --with-deps chromium", "test:jest:update": "jest --updateSnapshot", "test:update": "run-s build build:demo test:jest:update" @@ -79,6 +80,7 @@ "jest-junit": "^14.0.1", "mock-fs": "^5.2.0", "netlify-plugin-cypress": "^2.2.1", + "node-fetch": "^2.6.6", "npm-run-all": "^4.1.5", "outdent": "^0.8.0", "pathe": "^1.1.0", @@ -90,7 +92,8 @@ "sass": "^1.49.0", "sharp": "^0.30.4", "tmp-promise": "^3.0.2", - "typescript": "^4.3.4" + "typescript": "^4.3.4", + "wait-on": "^7.0.1" }, "dependencies": { "next": "^13.3.0" @@ -106,6 +109,7 @@ "**/test/**/*.spec.js", "**/test/**/*.spec.ts", "!**/test/e2e/**", + "!**/test/integration/**", "!**/test/fixtures/**", "!**/test/sample/**", "!**/test/templates/edge-shared/**" diff --git a/packages/runtime/src/helpers/analysis.ts b/packages/runtime/src/helpers/analysis.ts index 2dd1abad18..71cc417caa 100644 --- a/packages/runtime/src/helpers/analysis.ts +++ b/packages/runtime/src/helpers/analysis.ts @@ -2,12 +2,8 @@ import fs, { existsSync } from 'fs' import { relative } from 'pathe' -// I have no idea what eslint is up to here but it gives an error -// eslint-disable-next-line no-shadow -export const enum ApiRouteType { - SCHEDULED = 'experimental-scheduled', - BACKGROUND = 'experimental-background', -} +import { ApiRouteType } from './types' +import { findModuleFromBase } from './utils' export interface ApiStandardConfig { type?: never @@ -87,29 +83,37 @@ let hasWarnedAboutNextVersion = false /** * Uses Next's swc static analysis to extract the config values from a file. */ -export const extractConfigFromFile = async (apiFilePath: string): Promise => { +export const extractConfigFromFile = async (apiFilePath: string, appDir: string): Promise => { if (!apiFilePath || !existsSync(apiFilePath)) { return {} } - try { - if (!extractConstValue) { - extractConstValue = require('next/dist/build/analysis/extract-const-value') - } - if (!parseModule) { - // eslint-disable-next-line prefer-destructuring, @typescript-eslint/no-var-requires - parseModule = require('next/dist/build/analysis/parse-module').parseModule - } - } catch (error) { - if (error.code === 'MODULE_NOT_FOUND') { - if (!hasWarnedAboutNextVersion) { - console.log("This version of Next.js doesn't support advanced API routes. Skipping...") - hasWarnedAboutNextVersion = true - } - // Old Next.js version - return {} + const extractConstValueModulePath = findModuleFromBase({ + paths: [appDir], + candidates: ['next/dist/build/analysis/extract-const-value'], + }) + + const parseModulePath = findModuleFromBase({ + paths: [appDir], + candidates: ['next/dist/build/analysis/parse-module'], + }) + + if (!extractConstValueModulePath || !parseModulePath) { + if (!hasWarnedAboutNextVersion) { + console.log("This version of Next.js doesn't support advanced API routes. Skipping...") + hasWarnedAboutNextVersion = true } - throw error + // Old Next.js version + return {} + } + + if (!extractConstValue && extractConstValueModulePath) { + // eslint-disable-next-line import/no-dynamic-require + extractConstValue = require(extractConstValueModulePath) + } + if (!parseModule && parseModulePath) { + // eslint-disable-next-line prefer-destructuring, @typescript-eslint/no-var-requires, import/no-dynamic-require + parseModule = require(parseModulePath).parseModule } const { extractExportedConstValue, UnsupportedValueError } = extractConstValue diff --git a/packages/runtime/src/helpers/files.ts b/packages/runtime/src/helpers/files.ts index d97d614fb8..a2c7d9d47b 100644 --- a/packages/runtime/src/helpers/files.ts +++ b/packages/runtime/src/helpers/files.ts @@ -330,7 +330,7 @@ const patchFile = async ({ * The file we need has moved around a bit over the past few versions, * so we iterate through the options until we find it */ -const getServerFile = (root: string, includeBase = true) => { +export const getServerFile = (root: string, includeBase = true) => { const candidates = ['next/dist/server/next-server', 'next/dist/next-server/server/next-server'] if (includeBase) { diff --git a/packages/runtime/src/helpers/functions.ts b/packages/runtime/src/helpers/functions.ts index aa2423922e..021d89a645 100644 --- a/packages/runtime/src/helpers/functions.ts +++ b/packages/runtime/src/helpers/functions.ts @@ -20,9 +20,10 @@ import { getApiHandler } from '../templates/getApiHandler' import { getHandler } from '../templates/getHandler' import { getResolverForPages, getResolverForSourceFiles } from '../templates/getPageResolver' -import { ApiConfig, ApiRouteType, extractConfigFromFile, isEdgeConfig } from './analysis' -import { getSourceFileForPage } from './files' +import { ApiConfig, extractConfigFromFile, isEdgeConfig } from './analysis' +import { getServerFile, getSourceFileForPage } from './files' import { writeFunctionConfiguration } from './functionsMetaData' +import { ApiRouteType } from './types' import { getFunctionNameForPage } from './utils' export interface ApiRouteConfig { @@ -41,6 +42,11 @@ export const generateFunctions = async ( const functionDir = join(functionsDir, HANDLER_FUNCTION_NAME) const publishDir = relative(functionDir, publish) + const nextServerModuleAbsoluteLocation = getServerFile(appDir, false) + const nextServerModuleRelativeLocation = nextServerModuleAbsoluteLocation + ? relative(functionDir, nextServerModuleAbsoluteLocation) + : undefined + for (const { route, config, compiled } of apiRoutes) { // Don't write a lambda if the runtime is edge if (isEdgeConfig(config.runtime)) { @@ -51,6 +57,7 @@ export const generateFunctions = async ( config, publishDir, appDir: relative(functionDir, appDir), + nextServerModuleRelativeLocation, }) const functionName = getFunctionNameForPage(route, config.type === ApiRouteType.BACKGROUND) await ensureDir(join(functionsDir, functionName)) @@ -80,7 +87,12 @@ export const generateFunctions = async ( } const writeHandler = async (functionName: string, functionTitle: string, isODB: boolean) => { - const handlerSource = await getHandler({ isODB, publishDir, appDir: relative(functionDir, appDir) }) + const handlerSource = await getHandler({ + isODB, + publishDir, + appDir: relative(functionDir, appDir), + nextServerModuleRelativeLocation, + }) await ensureDir(join(functionsDir, functionName)) // write main handler file (standard or ODB) @@ -201,20 +213,20 @@ export const setupImageFunction = async ({ */ export const getApiRouteConfigs = async ( publish: string, - baseDir: string, + appDir: string, pageExtensions: string[], ): Promise> => { const pages = await readJSON(join(publish, 'server', 'pages-manifest.json')) const apiRoutes = Object.keys(pages).filter((page) => page.startsWith('/api/')) // two possible places // Ref: https://nextjs.org/docs/advanced-features/src-directory - const pagesDir = join(baseDir, 'pages') - const srcPagesDir = join(baseDir, 'src', 'pages') + const pagesDir = join(appDir, 'pages') + const srcPagesDir = join(appDir, 'src', 'pages') return await Promise.all( apiRoutes.map(async (apiRoute) => { const filePath = getSourceFileForPage(apiRoute, [pagesDir, srcPagesDir], pageExtensions) - return { route: apiRoute, config: await extractConfigFromFile(filePath), compiled: pages[apiRoute] } + return { route: apiRoute, config: await extractConfigFromFile(filePath, appDir), compiled: pages[apiRoute] } }), ) } @@ -224,10 +236,10 @@ export const getApiRouteConfigs = async ( */ export const getExtendedApiRouteConfigs = async ( publish: string, - baseDir: string, + appDir: string, pageExtensions: string[], ): Promise> => { - const settledApiRoutes = await getApiRouteConfigs(publish, baseDir, pageExtensions) + const settledApiRoutes = await getApiRouteConfigs(publish, appDir, pageExtensions) // We only want to return the API routes that are background or scheduled functions return settledApiRoutes.filter((apiRoute) => apiRoute.config.type !== undefined) diff --git a/packages/runtime/src/helpers/types.ts b/packages/runtime/src/helpers/types.ts index 1819f677b0..92cfadf5cc 100644 --- a/packages/runtime/src/helpers/types.ts +++ b/packages/runtime/src/helpers/types.ts @@ -51,3 +51,8 @@ export interface RoutesManifest { i18n: I18n rewrites: Rewrites } + +export const enum ApiRouteType { + SCHEDULED = 'experimental-scheduled', + BACKGROUND = 'experimental-background', +} diff --git a/packages/runtime/src/helpers/utils.ts b/packages/runtime/src/helpers/utils.ts index 2022fc6b00..4a0ead00f1 100644 --- a/packages/runtime/src/helpers/utils.ts +++ b/packages/runtime/src/helpers/utils.ts @@ -7,9 +7,8 @@ import { join } from 'pathe' import { OPTIONAL_CATCH_ALL_REGEX, CATCH_ALL_REGEX, DYNAMIC_PARAMETER_REGEX, HANDLER_FUNCTION_PATH } from '../constants' -import { ApiRouteType } from './analysis' import type { ApiRouteConfig } from './functions' -import { I18n } from './types' +import { I18n, ApiRouteType } from './types' const RESERVED_FILENAME = /[^\w_-]/g @@ -268,6 +267,17 @@ export const findModuleFromBase = ({ paths, candidates }): string | null => { // Ignore the error } } + // if we couldn't find a module from paths, let's try to resolve from here + for (const candidate of candidates) { + try { + const modulePath = require.resolve(candidate) + if (modulePath) { + return modulePath + } + } catch { + // Ignore the error + } + } return null } diff --git a/packages/runtime/src/templates/getApiHandler.ts b/packages/runtime/src/templates/getApiHandler.ts index 81d40e0674..d42bf4a7fe 100644 --- a/packages/runtime/src/templates/getApiHandler.ts +++ b/packages/runtime/src/templates/getApiHandler.ts @@ -3,8 +3,9 @@ import type { Bridge as NodeBridge } from '@vercel/node-bridge/bridge' // Aliasing like this means the editor may be able to syntax-highlight the string import { outdent as javascript } from 'outdent' -import { ApiConfig, ApiRouteType } from '../helpers/analysis' +import { ApiConfig } from '../helpers/analysis' import type { NextConfig } from '../helpers/config' +import { ApiRouteType } from '../helpers/types' import type { NextServerType } from './handlerUtils' @@ -17,16 +18,23 @@ const { URLSearchParams, URL } = require('url') const { Bridge } = require('@vercel/node-bridge/bridge') -const { getMultiValueHeaders, getNextServer } = require('./handlerUtils') +const { getMultiValueHeaders } = require('./handlerUtils') /* eslint-enable @typescript-eslint/no-var-requires */ type Mutable = { -readonly [K in keyof T]: T[K] } -// We return a function and then call `toString()` on it to serialise it as the launcher function +type MakeApiHandlerParams = { + conf: NextConfig + app: string + pageRoot: string + page: string + NextServer: NextServerType +} -const makeHandler = (conf: NextConfig, app, pageRoot, page) => { +// We return a function and then call `toString()` on it to serialise it as the launcher function +const makeApiHandler = ({ conf, app, pageRoot, page, NextServer }: MakeApiHandlerParams) => { // Change working directory into the site root, unless using Nx, which moves the // dist directory and handles this itself const dir = path.resolve(__dirname, app) @@ -64,7 +72,6 @@ const makeHandler = (conf: NextConfig, app, pageRoot, page) => { const url = event.rawUrl ? new URL(event.rawUrl) : new URL(path, process.env.URL || 'http://n') const port = Number.parseInt(url.port) || 80 - const NextServer: NextServerType = getNextServer() const nextServer = new NextServer({ conf, dir, @@ -118,18 +125,25 @@ export const getApiHandler = ({ config, publishDir = '../../../.next', appDir = '../../..', + nextServerModuleRelativeLocation, }: { page: string config: ApiConfig publishDir?: string appDir?: string + nextServerModuleRelativeLocation: string | undefined }): string => // This is a string, but if you have the right editor plugin it should format as js javascript/* javascript */ ` + if (!${JSON.stringify(nextServerModuleRelativeLocation)}) { + throw new Error('Could not find Next.js server') + } + const { Server } = require("http"); // We copy the file here rather than requiring from the node module const { Bridge } = require("./bridge"); - const { getMultiValueHeaders, getNextServer } = require('./handlerUtils') + const { getMultiValueHeaders } = require('./handlerUtils') + const NextServer = require(${JSON.stringify(nextServerModuleRelativeLocation)}).default ${config.type === ApiRouteType.SCHEDULED ? `const { schedule } = require("@netlify/functions")` : ''} @@ -138,7 +152,9 @@ export const getApiHandler = ({ let staticManifest const path = require("path"); const pageRoot = path.resolve(path.join(__dirname, "${publishDir}", "server")); - const handler = (${makeHandler.toString()})(config, "${appDir}", pageRoot, ${JSON.stringify(page)}) + const handler = (${makeApiHandler.toString()})({ conf: config, app: "${appDir}", pageRoot, page:${JSON.stringify( + page, + )}, NextServer}) exports.handler = ${ config.type === ApiRouteType.SCHEDULED ? `schedule(${JSON.stringify(config.schedule)}, handler);` : 'handler' } diff --git a/packages/runtime/src/templates/getHandler.ts b/packages/runtime/src/templates/getHandler.ts index d8e3bdc7ca..5d7d2b5ecb 100644 --- a/packages/runtime/src/templates/getHandler.ts +++ b/packages/runtime/src/templates/getHandler.ts @@ -5,6 +5,9 @@ import { outdent as javascript } from 'outdent' import type { NextConfig } from '../helpers/config' +import type { NextServerType } from './handlerUtils' +import type { NetlifyNextServerType } from './server' + /* eslint-disable @typescript-eslint/no-var-requires */ const { promises } = require('fs') const { Server } = require('http') @@ -21,16 +24,25 @@ const { getPrefetchResponse, normalizePath, } = require('./handlerUtils') -const { NetlifyNextServer } = require('./server') +const { getNetlifyNextServer } = require('./server') /* eslint-enable @typescript-eslint/no-var-requires */ type Mutable = { -readonly [K in keyof T]: T[K] } +type MakeHandlerParams = { + conf: NextConfig + app: string + pageRoot: string + NextServer: NextServerType + staticManifest: Array<[string, string]> + mode: 'ssr' | 'odb' +} + // We return a function and then call `toString()` on it to serialise it as the launcher function -// eslint-disable-next-line max-params, max-lines-per-function -const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[string, string]> = [], mode = 'ssr') => { +// eslint-disable-next-line max-lines-per-function +const makeHandler = ({ conf, app, pageRoot, NextServer, staticManifest = [], mode = 'ssr' }: MakeHandlerParams) => { // Change working directory into the site root, unless using Nx, which moves the // dist directory and handles this itself const dir = path.resolve(__dirname, app) @@ -44,6 +56,8 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str require.resolve('./pages.js') } catch {} + const NetlifyNextServer: NetlifyNextServerType = getNetlifyNextServer(NextServer) + const ONE_YEAR_IN_SECONDS = 31536000 // React assumes you want development mode if NODE_ENV is unset. @@ -104,7 +118,7 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str } return async function handler(event: HandlerEvent, context: HandlerContext) { - let requestMode = mode + let requestMode: string = mode const prefetchResponse = getPrefetchResponse(event, mode) if (prefetchResponse) { return prefetchResponse @@ -169,15 +183,25 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str } } -export const getHandler = ({ isODB = false, publishDir = '../../../.next', appDir = '../../..' }): string => +export const getHandler = ({ + isODB = false, + publishDir = '../../../.next', + appDir = '../../..', + nextServerModuleRelativeLocation, +}): string => // This is a string, but if you have the right editor plugin it should format as js javascript/* javascript */ ` + if (!${JSON.stringify(nextServerModuleRelativeLocation)}) { + throw new Error('Could not find Next.js server') + } + const { Server } = require("http"); const { promises } = require("fs"); // We copy the file here rather than requiring from the node module const { Bridge } = require("./bridge"); - const { augmentFsModule, getMaxAge, getMultiValueHeaders, getPrefetchResponse, getNextServer, normalizePath } = require('./handlerUtils') - const { NetlifyNextServer } = require('./server') + const { augmentFsModule, getMaxAge, getMultiValueHeaders, getPrefetchResponse, normalizePath } = require('./handlerUtils') + const { getNetlifyNextServer } = require('./server') + const NextServer = require(${JSON.stringify(nextServerModuleRelativeLocation)}).default ${isODB ? `const { builder } = require("@netlify/functions")` : ''} const { config } = require("${publishDir}/required-server-files.json") @@ -189,7 +213,7 @@ export const getHandler = ({ isODB = false, publishDir = '../../../.next', appDi const pageRoot = path.resolve(path.join(__dirname, "${publishDir}", "server")); exports.handler = ${ isODB - ? `builder((${makeHandler.toString()})(config, "${appDir}", pageRoot, staticManifest, 'odb'));` - : `(${makeHandler.toString()})(config, "${appDir}", pageRoot, staticManifest, 'ssr');` + ? `builder((${makeHandler.toString()})({ conf: config, app: "${appDir}", pageRoot, NextServer, staticManifest, mode: 'odb' }));` + : `(${makeHandler.toString()})({ conf: config, app: "${appDir}", pageRoot, NextServer, staticManifest, mode: 'ssr' });` } ` diff --git a/packages/runtime/src/templates/handlerUtils.ts b/packages/runtime/src/templates/handlerUtils.ts index cbb26e9d58..0d985c21a3 100644 --- a/packages/runtime/src/templates/handlerUtils.ts +++ b/packages/runtime/src/templates/handlerUtils.ts @@ -160,37 +160,6 @@ export const augmentFsModule = ({ }) as typeof promises.stat } -/** - * Next.js has an annoying habit of needing deep imports, but then moving those in patch releases. This is our abstraction. - */ -export const getNextServer = (): NextServerType => { - let NextServer: NextServerType - try { - // next >= 11.0.1. Yay breaking changes in patch releases! - // eslint-disable-next-line @typescript-eslint/no-var-requires - NextServer = require('next/dist/server/next-server').default - } catch (error) { - if (!error.message.includes("Cannot find module 'next/dist/server/next-server'")) { - // A different error, so rethrow it - throw error - } - // Probably an old version of next, so fall through and find it elsewhere. - } - - if (!NextServer) { - try { - // next < 11.0.1 - // eslint-disable-next-line n/no-missing-require, import/no-unresolved, @typescript-eslint/no-var-requires - NextServer = require('next/dist/next-server/server/next-server').default - } catch (error) { - if (!error.message.includes("Cannot find module 'next/dist/next-server/server/next-server'")) { - throw error - } - throw new Error('Could not find Next.js server') - } - } - return NextServer -} /** * Prefetch requests are used to check for middleware redirects, and shouldn't trigger SSR. */ diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index ad0908bc3b..7fb61661eb 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -3,7 +3,6 @@ import { NodeRequestHandler, Options } from 'next/dist/server/next-server' import { netlifyApiFetch, - getNextServer, NextServerType, normalizeRoute, localizeRoute, @@ -11,94 +10,98 @@ import { unlocalizeRoute, } from './handlerUtils' -const NextServer: NextServerType = getNextServer() - interface NetlifyConfig { revalidateToken?: string } -class NetlifyNextServer extends NextServer { - private netlifyConfig: NetlifyConfig - private netlifyPrerenderManifest: PrerenderManifest +const getNetlifyNextServer = (NextServer: NextServerType) => { + class NetlifyNextServer extends NextServer { + private netlifyConfig: NetlifyConfig + private netlifyPrerenderManifest: PrerenderManifest - public constructor(options: Options, netlifyConfig: NetlifyConfig) { - super(options) - this.netlifyConfig = netlifyConfig - // copy the prerender manifest so it doesn't get mutated by Next.js - const manifest = this.getPrerenderManifest() - this.netlifyPrerenderManifest = { - ...manifest, - routes: { ...manifest.routes }, - dynamicRoutes: { ...manifest.dynamicRoutes }, + public constructor(options: Options, netlifyConfig: NetlifyConfig) { + super(options) + this.netlifyConfig = netlifyConfig + // copy the prerender manifest so it doesn't get mutated by Next.js + const manifest = this.getPrerenderManifest() + this.netlifyPrerenderManifest = { + ...manifest, + routes: { ...manifest.routes }, + dynamicRoutes: { ...manifest.dynamicRoutes }, + } } - } - public getRequestHandler(): NodeRequestHandler { - const handler = super.getRequestHandler() - return async (req, res, parsedUrl) => { - // preserve the URL before Next.js mutates it for i18n - const { url, headers } = req - // handle the original res.revalidate() request - await handler(req, res, parsedUrl) - // handle on-demand revalidation by purging the ODB cache - if (res.statusCode === 200 && headers['x-prerender-revalidate'] && this.netlifyConfig.revalidateToken) { - await this.netlifyRevalidate(url) + public getRequestHandler(): NodeRequestHandler { + const handler = super.getRequestHandler() + return async (req, res, parsedUrl) => { + // preserve the URL before Next.js mutates it for i18n + const { url, headers } = req + // handle the original res.revalidate() request + await handler(req, res, parsedUrl) + // handle on-demand revalidation by purging the ODB cache + if (res.statusCode === 200 && headers['x-prerender-revalidate'] && this.netlifyConfig.revalidateToken) { + await this.netlifyRevalidate(url) + } } } - } - private async netlifyRevalidate(route: string) { - try { - // call netlify API to revalidate the path - const result = await netlifyApiFetch<{ ok: boolean; code: number; message: string }>({ - endpoint: `sites/${process.env.SITE_ID}/refresh_on_demand_builders`, - payload: { - paths: this.getNetlifyPathsForRoute(route), - domain: this.hostname, - }, - token: this.netlifyConfig.revalidateToken, - method: 'POST', - }) - if (!result.ok) { - throw new Error(result.message) + private async netlifyRevalidate(route: string) { + try { + // call netlify API to revalidate the path + const result = await netlifyApiFetch<{ ok: boolean; code: number; message: string }>({ + endpoint: `sites/${process.env.SITE_ID}/refresh_on_demand_builders`, + payload: { + paths: this.getNetlifyPathsForRoute(route), + domain: this.hostname, + }, + token: this.netlifyConfig.revalidateToken, + method: 'POST', + }) + if (!result.ok) { + throw new Error(result.message) + } + } catch (error) { + console.log(`Error revalidating ${route}:`, error.message) + throw error } - } catch (error) { - console.log(`Error revalidating ${route}:`, error.message) - throw error } - } - - private getNetlifyPathsForRoute(route: string): string[] { - const { i18n } = this.nextConfig - const { routes, dynamicRoutes } = this.netlifyPrerenderManifest - // matches static routes - const normalizedRoute = normalizeRoute(i18n ? localizeRoute(route, i18n) : route) - if (normalizedRoute in routes) { - const { dataRoute } = routes[normalizedRoute] - const normalizedDataRoute = i18n ? localizeDataRoute(dataRoute, normalizedRoute) : dataRoute - return [route, normalizedDataRoute] - } + private getNetlifyPathsForRoute(route: string): string[] { + const { i18n } = this.nextConfig + const { routes, dynamicRoutes } = this.netlifyPrerenderManifest - // matches dynamic routes - const unlocalizedRoute = i18n ? unlocalizeRoute(normalizedRoute, i18n) : normalizedRoute - for (const dynamicRoute in dynamicRoutes) { - const { dataRoute, routeRegex } = dynamicRoutes[dynamicRoute] - const matches = unlocalizedRoute.match(routeRegex) - if (matches && matches.length !== 0) { - // remove the first match, which is the full route - matches.shift() - // replace the dynamic segments with the actual values - const interpolatedDataRoute = dataRoute.replace(/\[(.*?)]/g, () => matches.shift()) - const normalizedDataRoute = i18n - ? localizeDataRoute(interpolatedDataRoute, normalizedRoute) - : interpolatedDataRoute + // matches static routes + const normalizedRoute = normalizeRoute(i18n ? localizeRoute(route, i18n) : route) + if (normalizedRoute in routes) { + const { dataRoute } = routes[normalizedRoute] + const normalizedDataRoute = i18n ? localizeDataRoute(dataRoute, normalizedRoute) : dataRoute return [route, normalizedDataRoute] } - } - throw new Error(`not an ISR route`) + // matches dynamic routes + const unlocalizedRoute = i18n ? unlocalizeRoute(normalizedRoute, i18n) : normalizedRoute + for (const dynamicRoute in dynamicRoutes) { + const { dataRoute, routeRegex } = dynamicRoutes[dynamicRoute] + const matches = unlocalizedRoute.match(routeRegex) + if (matches?.length > 0) { + // remove the first match, which is the full route + matches.shift() + // replace the dynamic segments with the actual values + const interpolatedDataRoute = dataRoute.replace(/\[(.*?)]/g, () => matches.shift()) + const normalizedDataRoute = i18n + ? localizeDataRoute(interpolatedDataRoute, normalizedRoute) + : interpolatedDataRoute + return [route, normalizedDataRoute] + } + } + + throw new Error(`not an ISR route`) + } } + + return NetlifyNextServer } -export { NetlifyNextServer, NetlifyConfig } +export type NetlifyNextServerType = ReturnType + +export { getNetlifyNextServer, NetlifyConfig } diff --git a/test/helpers/analysis.spec.ts b/test/helpers/analysis.spec.ts index 0aa0748137..ee96ce02a7 100644 --- a/test/helpers/analysis.spec.ts +++ b/test/helpers/analysis.spec.ts @@ -12,29 +12,29 @@ describe('static source analysis', () => { ;(console.error as jest.Mock).mockRestore() }) it('should extract config values from a source file', async () => { - const config = await extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/background.js')) + const config = await extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/background.js'), process.cwd()) expect(config).toEqual({ type: 'experimental-background', }) }) it('should extract config values from a TypeScript source file', async () => { - const config = await extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/background.ts')) + const config = await extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/background.ts'), process.cwd()) expect(config).toEqual({ type: 'experimental-background', }) }) it('should return an empty config if not defined', async () => { - const config = await extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/missing.ts')) + const config = await extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/missing.ts'), process.cwd()) expect(config).toEqual({}) }) it('should return an empty config if config is invalid', async () => { - const config = await extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/invalid.ts')) + const config = await extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/invalid.ts'), process.cwd()) expect(config).toEqual({}) }) it('should extract schedule values from a source file', async () => { - const config = await extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/scheduled.ts')) + const config = await extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/scheduled.ts'), process.cwd()) expect(config).toEqual({ type: 'experimental-scheduled', schedule: '@daily', @@ -42,40 +42,40 @@ describe('static source analysis', () => { }) it('should throw if schedule is provided when type is background', async () => { await expect( - extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/background-schedule.ts')), + extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/background-schedule.ts'), process.cwd()), ).rejects.toThrow('Unsupported config value in test/fixtures/analysis/background-schedule.ts') expect(console.error).toHaveBeenCalledWith( `Invalid config value in test/fixtures/analysis/background-schedule.ts: schedule is not allowed unless type is "experimental-scheduled"`, ) }) it('should throw if schedule is provided when type is default', async () => { - await expect(extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/default-schedule.ts'))).rejects.toThrow( - 'Unsupported config value in test/fixtures/analysis/default-schedule.ts', - ) + await expect( + extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/default-schedule.ts'), process.cwd()), + ).rejects.toThrow('Unsupported config value in test/fixtures/analysis/default-schedule.ts') expect(console.error).toHaveBeenCalledWith( `Invalid config value in test/fixtures/analysis/default-schedule.ts: schedule is not allowed unless type is "experimental-scheduled"`, ) }) it('should throw if schedule is not provided when type is scheduled', async () => { - await expect(extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/missing-schedule.ts'))).rejects.toThrow( - 'Unsupported config value in test/fixtures/analysis/missing-schedule.ts', - ) + await expect( + extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/missing-schedule.ts'), process.cwd()), + ).rejects.toThrow('Unsupported config value in test/fixtures/analysis/missing-schedule.ts') expect(console.error).toHaveBeenCalledWith( `Invalid config value in test/fixtures/analysis/missing-schedule.ts: schedule is required when type is "experimental-scheduled"`, ) }) it('should throw if edge runtime is specified for scheduled functions', async () => { - await expect(extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/scheduled-edge.ts'))).rejects.toThrow( - 'Unsupported config value in test/fixtures/analysis/scheduled-edge.ts', - ) + await expect( + extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/scheduled-edge.ts'), process.cwd()), + ).rejects.toThrow('Unsupported config value in test/fixtures/analysis/scheduled-edge.ts') expect(console.error).toHaveBeenCalledWith( `Invalid config value in test/fixtures/analysis/scheduled-edge.ts: edge runtime is not supported for scheduled functions`, ) }) it('should throw if edge runtime is specified for background functions', async () => { - await expect(extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/background-edge.ts'))).rejects.toThrow( - 'Unsupported config value in test/fixtures/analysis/background-edge.ts', - ) + await expect( + extractConfigFromFile(resolve(__dirname, '../fixtures/analysis/background-edge.ts'), process.cwd()), + ).rejects.toThrow('Unsupported config value in test/fixtures/analysis/background-edge.ts') expect(console.error).toHaveBeenCalledWith( `Invalid config value in test/fixtures/analysis/background-edge.ts: edge runtime is not supported for background functions`, ) diff --git a/test/index.spec.ts b/test/index.spec.ts index 1e9e5279f6..7c10448c7a 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -550,8 +550,12 @@ describe('onBuild()', () => { expect(existsSync(handlerFile)).toBeTruthy() expect(existsSync(odbHandlerFile)).toBeTruthy() - expect(readFileSync(handlerFile, 'utf8')).toMatch(`(config, "../../..", pageRoot, staticManifest, 'ssr')`) - expect(readFileSync(odbHandlerFile, 'utf8')).toMatch(`(config, "../../..", pageRoot, staticManifest, 'odb')`) + expect(readFileSync(handlerFile, 'utf8')).toMatch( + `({ conf: config, app: "../../..", pageRoot, NextServer, staticManifest, mode: 'ssr' })`, + ) + expect(readFileSync(odbHandlerFile, 'utf8')).toMatch( + `({ conf: config, app: "../../..", pageRoot, NextServer, staticManifest, mode: 'odb' })`, + ) expect(readFileSync(handlerFile, 'utf8')).toMatch(`require("../../../.next/required-server-files.json")`) expect(readFileSync(odbHandlerFile, 'utf8')).toMatch(`require("../../../.next/required-server-files.json")`) }) diff --git a/test/integration/fixtures/next-package-not-resolvable-in-base-dir/clear-node-modules-after-functions-bundling-plugin/index.js b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/clear-node-modules-after-functions-bundling-plugin/index.js new file mode 100644 index 0000000000..3ac501d059 --- /dev/null +++ b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/clear-node-modules-after-functions-bundling-plugin/index.js @@ -0,0 +1,12 @@ +const fs = require(`fs`) +const path = require(`path`) + +module.exports = { + onPostBuild: async () => { + const movedDir = path.join(process.cwd(), `next-app`, `node_modules2`) + try { + fs.unlinkSync(movedDir) + } catch {} + fs.renameSync(path.join(process.cwd(), `next-app`, `node_modules`), movedDir) + }, +} diff --git a/test/integration/fixtures/next-package-not-resolvable-in-base-dir/clear-node-modules-after-functions-bundling-plugin/manifest.yaml b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/clear-node-modules-after-functions-bundling-plugin/manifest.yaml new file mode 100644 index 0000000000..1727ced689 --- /dev/null +++ b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/clear-node-modules-after-functions-bundling-plugin/manifest.yaml @@ -0,0 +1 @@ +name: clear-node-modules-after-functions-bundling-plugin diff --git a/test/integration/fixtures/next-package-not-resolvable-in-base-dir/clear-node-modules-after-functions-bundling-plugin/package.json b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/clear-node-modules-after-functions-bundling-plugin/package.json new file mode 100644 index 0000000000..593181fd63 --- /dev/null +++ b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/clear-node-modules-after-functions-bundling-plugin/package.json @@ -0,0 +1,3 @@ +{ + "name": "clear-node-modules-after-functions-bundling-plugin" +} diff --git a/test/integration/fixtures/next-package-not-resolvable-in-base-dir/netlify.toml b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/netlify.toml new file mode 100644 index 0000000000..29f97224b8 --- /dev/null +++ b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/netlify.toml @@ -0,0 +1,9 @@ +[build] +command = "cd next-app; npm install; npm run build" +publish = "next-app/.next" + +[[plugins]] +package = "@netlify/plugin-nextjs" + +[[plugins]] +package = "/clear-node-modules-after-functions-bundling-plugin" diff --git a/test/integration/fixtures/next-package-not-resolvable-in-base-dir/next-app/package.json b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/next-app/package.json new file mode 100644 index 0000000000..527ecb61eb --- /dev/null +++ b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/next-app/package.json @@ -0,0 +1,16 @@ +{ + "name": "my-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "13.2.4", + "react": "18.2.0", + "react-dom": "18.2.0" + } +} diff --git a/test/integration/fixtures/next-package-not-resolvable-in-base-dir/next-app/pages/api/hello.js b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/next-app/pages/api/hello.js new file mode 100644 index 0000000000..bf4cbe226e --- /dev/null +++ b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/next-app/pages/api/hello.js @@ -0,0 +1,3 @@ +module.exports = function handler(req, res) { + res.status(200).json({ name: 'John Doe' }) +} diff --git a/test/integration/fixtures/next-package-not-resolvable-in-base-dir/next-app/pages/index.js b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/next-app/pages/index.js new file mode 100644 index 0000000000..f33154f21e --- /dev/null +++ b/test/integration/fixtures/next-package-not-resolvable-in-base-dir/next-app/pages/index.js @@ -0,0 +1,24 @@ +import Head from 'next/head' + +export default function Home() { + return ( + <> + + Create Next App + + + +
+
Hello world
+
+ + ) +} + +export const getServerSideProps = async ({ params }) => { + return { + props: { + ssr: true, + }, + } +} diff --git a/test/integration/jest.config.js b/test/integration/jest.config.js new file mode 100644 index 0000000000..e5d5909f84 --- /dev/null +++ b/test/integration/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + transform: { + '\\.[jt]sx?$': 'babel-jest', + }, + verbose: true, + testTimeout: 60000, + maxWorkers: 1, +} diff --git a/test/integration/next-package-not-resolvable-in-base-dir.spec.ts b/test/integration/next-package-not-resolvable-in-base-dir.spec.ts new file mode 100644 index 0000000000..1c84d113ea --- /dev/null +++ b/test/integration/next-package-not-resolvable-in-base-dir.spec.ts @@ -0,0 +1,93 @@ +import { tmpdir } from 'os' +import { join } from 'path' + +import execa from 'execa' +import { mkdtempSync, copySync } from 'fs-extra' +import fetch from 'node-fetch' +import waitOn from 'wait-on' + +let destroy = () => {} + +beforeAll(async () => { + const tmpDir = mkdtempSync(join(tmpdir(), `isolated-test-`)) + + // copy fixture to isolated tmp directory + const siteSrcDir = join(__dirname, 'fixtures', 'next-package-not-resolvable-in-base-dir') + const siteDestDir = join(tmpDir, 'next-package-not-resolvable-in-base-dir') + + console.log(`copying fixture site "${siteSrcDir}" to "${siteDestDir}"`) + copySync(siteSrcDir, siteDestDir) + + // bump version so no npm cache tries to use what's in npm registry + const runtimeSrcDir = join(__dirname, '..', '..', 'packages', 'runtime') + await execa(`npm`, [`version`, `prerelease`, `--no-git-tag-version`], { cwd: runtimeSrcDir, stdio: `inherit` }) + + // create package tarball + const o = await execa(`npm`, [`pack`, `--json`], { cwd: runtimeSrcDir }) + const tgzName = JSON.parse(o.stdout)[0].filename + const tgzPath = join(runtimeSrcDir, tgzName) + + // install runtime from tarball + await execa(`npm`, [`install`, tgzPath], { cwd: siteDestDir, stdio: `inherit` }) + + return new Promise(async (resolve, reject) => { + try { + // run + let isServeRunning = true + const serveProcess = execa('netlify', ['serve', `-p`, `8888`], { cwd: siteDestDir, stdio: `inherit` }) + + let shouldRejectOnNonZeroProcessExit = true + serveProcess.catch((error) => { + isServeRunning = false + if (shouldRejectOnNonZeroProcessExit) { + reject(error) + } + return null + }) + + await waitOn({ + resources: [`http://localhost:8888/`], + timeout: 3 * 60 * 1000, + }) + + if (!isServeRunning) { + return reject(new Error(`serve process exited`)) + } + + destroy = () => { + shouldRejectOnNonZeroProcessExit = false + serveProcess.kill() + } + + // ensure we can't resolve "next" from either base dir or app dir + // this is done to ensure that functions packaging worked correctly and doesn't rely on + // leftover node_modules that wouldn't be available when functions are deployed to lambdas + expect(() => require.resolve(`next`, { paths: [siteDestDir] })).toThrow() + expect(() => require.resolve(`next`, { paths: [join(siteDestDir, `next-app`)] })).toThrow() + + return resolve() + } catch (error) { + reject(error) + } + }) +}, 3 * 60 * 1000) + +afterAll(() => destroy()) + +it(`page route executes correctly`, async () => { + const htmlResponse = await fetch(`http://localhost:8888/`) + // ensure we got a 200 + expect(htmlResponse.ok).toBe(true) + // ensure we use ssr handler + expect(htmlResponse.headers.get(`x-nf-render-mode`)).toEqual(`ssr`) + const t = expect(await htmlResponse.text()).toMatch(/Hello world/) +}) + +it(`api route executes correctly`, async () => { + const apiResponse = await fetch(`http://localhost:8888/api/hello`) + // ensure we got a 200 + expect(apiResponse.ok).toBe(true) + // ensure we use ssr handler + expect(apiResponse.headers.get(`x-nf-render-mode`)).toEqual(`ssr`) + expect(await apiResponse.json()).toEqual({ name: 'John Doe' }) +}) diff --git a/test/templates/server.spec.ts b/test/templates/server.spec.ts index 0ee660df8e..2f0155e106 100644 --- a/test/templates/server.spec.ts +++ b/test/templates/server.spec.ts @@ -1,8 +1,9 @@ import { createRequestResponseMocks } from 'next/dist/server/lib/mock-request' import { Options } from 'next/dist/server/next-server' -import { getNextServer, NextServerType, netlifyApiFetch } from '../../packages/runtime/src/templates/handlerUtils' -import { NetlifyNextServer, NetlifyConfig } from '../../packages/runtime/src/templates/server' +import { getServerFile } from '../../packages/runtime/src/helpers/files' +import { NextServerType, netlifyApiFetch } from '../../packages/runtime/src/templates/handlerUtils' +import { getNetlifyNextServer, NetlifyNextServerType, NetlifyConfig } from '../../packages/runtime/src/templates/server' jest.mock('../../packages/runtime/src/templates/handlerUtils', () => { const originalModule = jest.requireActual('../../packages/runtime/src/templates/handlerUtils') @@ -53,9 +54,11 @@ jest.mock( { virtual: true }, ) +let NetlifyNextServer: NetlifyNextServerType beforeAll(() => { - const NextServer: NextServerType = getNextServer() + const NextServer: NextServerType = require(getServerFile(__dirname, false)).default jest.spyOn(NextServer.prototype, 'getRequestHandler').mockImplementation(() => () => Promise.resolve()) + NetlifyNextServer = getNetlifyNextServer(NextServer) const MockNetlifyNextServerConstructor = function (nextOptions: Options, netlifyConfig: NetlifyConfig) { this.distDir = '.'