diff --git a/.changeset/little-hotels-wave.md b/.changeset/little-hotels-wave.md new file mode 100644 index 000000000..bbed87498 --- /dev/null +++ b/.changeset/little-hotels-wave.md @@ -0,0 +1,20 @@ +--- +'eslint-plugin-next-on-pages': patch +'@cloudflare/next-on-pages': patch +--- + +Add workaround so that people can use standard not-found routes in their app directory applications + +The problem is that: + +- if there's a static not-found route in app dir, that generates a serverless (edge incompatible) function (\_not-found) +- if there's a dynamic not-found route in app dir, that generates two serverless (edge incompatible) functions (\_not-found, \_error) + +The workaround being introduced here is: + +- if there's a static not-found route in app dir, we delete the generated \_not-found serverless function + (which is not needed as we can just fallback to the static 404 html) +- if there's a dynamic not-found route in app dir, we can't actually fix it but provide a warning for the user + +Besides the above the `no-app-not-found-runtime` eslint rule has been introduced to try to help developers avoid +the issue diff --git a/packages/eslint-plugin-next-on-pages/docs/rules/no-app-not-found-runtime.md b/packages/eslint-plugin-next-on-pages/docs/rules/no-app-not-found-runtime.md new file mode 100644 index 000000000..e00aa4dcc --- /dev/null +++ b/packages/eslint-plugin-next-on-pages/docs/rules/no-app-not-found-runtime.md @@ -0,0 +1,51 @@ +# `next-on-pages/no-app-not-found-runtime` + +[Not found](https://nextjs.org/docs/app/api-reference/file-conventions/not-found) app router components are not currently supported with `@cloudflare/next-on-pages`, the only current alternative is not to export a runtime and making sure that the component doesn't have runtime logic. In such a way a static 404 page gets generated during the build time and served to users. + +> **Note** +> This restriction applies only to top-level not-found pages (present in the `app` directory), not-found pages +> nested inside routes are handled correctly and can contain runtime logic. + +## ❌ Invalid Code + +```js +// app/not-found.jsx + +export const runtime = 'edge'; +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +> **Warning** +> The following code is invalid but not caught by the rule, please be aware of such limitation + +```js +// app/not-found.jsx + +import { cookies } from 'next/headers'; + +export default function NotFound() { + // notice the runtime cookie retrieval + const cookieStore = cookies(); + const theme = cookieStore.get('theme'); + + return ( +
+

Not Found

+
+ ); +} +``` + +## ✅ Valid Code + +```js +// app/not-found.jsx + +export default function NotFound() { + return ( +
+

Not Found

+
+ ); +} +``` diff --git a/packages/eslint-plugin-next-on-pages/src/index.ts b/packages/eslint-plugin-next-on-pages/src/index.ts index 17baa167b..161b600e9 100644 --- a/packages/eslint-plugin-next-on-pages/src/index.ts +++ b/packages/eslint-plugin-next-on-pages/src/index.ts @@ -1,5 +1,6 @@ import noNodeJsRuntime from './rules/no-nodejs-runtime'; import noUnsupportedConfigs from './rules/no-unsupported-configs'; +import noAppNotFound from './rules/no-app-not-found-runtime'; import type { ESLint } from 'eslint'; @@ -7,11 +8,13 @@ const config: ESLint.Plugin = { rules: { 'no-nodejs-runtime': noNodeJsRuntime, 'no-unsupported-configs': noUnsupportedConfigs, + 'no-app-not-found': noAppNotFound, }, configs: { recommended: { plugins: ['eslint-plugin-next-on-pages'], rules: { + 'next-on-pages/no-app-not-found': 'error', 'next-on-pages/no-nodejs-runtime': 'error', 'next-on-pages/no-unsupported-configs': 'error', }, diff --git a/packages/eslint-plugin-next-on-pages/src/rules/no-app-not-found-runtime.ts b/packages/eslint-plugin-next-on-pages/src/rules/no-app-not-found-runtime.ts new file mode 100644 index 000000000..e3deb2780 --- /dev/null +++ b/packages/eslint-plugin-next-on-pages/src/rules/no-app-not-found-runtime.ts @@ -0,0 +1,42 @@ +import type { Rule } from 'eslint'; + +const rule: Rule.RuleModule = { + create: context => { + const isAppNotFoundRoute = new RegExp( + `${context.cwd}/app/not-found\\.(tsx|jsx)`, + ).test(context.filename); + return { + ExportNamedDeclaration: node => { + if (!isAppNotFoundRoute) { + // This rule only applies to app/not-found routes + return; + } + + const declaration = node.declaration; + if ( + declaration?.type === 'VariableDeclaration' && + declaration.declarations.length === 1 && + declaration.declarations[0]?.id.type === 'Identifier' && + declaration.declarations[0].id.name === 'runtime' && + declaration.declarations[0].init?.type === 'Literal' + ) { + context.report({ + message: + 'Only static not-found pages are currently supported, please remove the runtime export.' + + context.filename, + node, + fix: fixer => fixer.remove(node), + }); + } + }, + }; + }, + meta: { + fixable: 'code', + docs: { + url: 'https://github.com/cloudflare/next-on-pages/blob/main/packages/eslint-plugin-next-on-pages/docs/rules/no-app-not-found-runtime.md', + }, + }, +}; + +export = rule; diff --git a/packages/next-on-pages/src/buildApplication/processVercelFunctions/index.ts b/packages/next-on-pages/src/buildApplication/processVercelFunctions/index.ts index 55546aaef..11ed8be32 100644 --- a/packages/next-on-pages/src/buildApplication/processVercelFunctions/index.ts +++ b/packages/next-on-pages/src/buildApplication/processVercelFunctions/index.ts @@ -4,7 +4,7 @@ import { processEdgeFunctions } from './edgeFunctions'; import { processPrerenderFunctions } from './prerenderFunctions'; import type { CollectedFunctionIdentifiers } from './dedupeEdgeFunctions'; import { dedupeEdgeFunctions } from './dedupeEdgeFunctions'; -import { checkForInvalidFunctions } from './invalidFunctions'; +import { checkInvalidFunctions } from './invalidFunctions'; /** * Processes and dedupes the Vercel build output functions directory. @@ -23,7 +23,7 @@ export async function processVercelFunctions( await processEdgeFunctions(collectedFunctions); - await checkForInvalidFunctions(collectedFunctions); + await checkInvalidFunctions(collectedFunctions); const identifiers = await dedupeEdgeFunctions(collectedFunctions, opts); diff --git a/packages/next-on-pages/src/buildApplication/processVercelFunctions/invalidFunctions.ts b/packages/next-on-pages/src/buildApplication/processVercelFunctions/invalidFunctions.ts index af0aa501b..0d68e67aa 100644 --- a/packages/next-on-pages/src/buildApplication/processVercelFunctions/invalidFunctions.ts +++ b/packages/next-on-pages/src/buildApplication/processVercelFunctions/invalidFunctions.ts @@ -1,19 +1,25 @@ import { gtr as versionGreaterThan, coerce } from 'semver'; -import { cliError } from '../../cli'; +import { cliError, cliWarn } from '../../cli'; import { getPackageVersion } from '../packageManagerUtils'; import { stripFuncExtension } from '../../utils'; import type { CollectedFunctions, FunctionInfo } from './configs'; +import { join, resolve } from 'path'; /** * Checks if there are any invalid functions from the Vercel build output. * - * If there are any invalid functions, an error message will be printed and the process will exit. + * If there are any invalid functions it will try to see if they are amendable and if the + * build output can still be used. + * + * If however the build output can't be used, an error message will be printed and the process will exit. * * @param collectedFunctions Collected functions from the Vercel build output. */ -export async function checkForInvalidFunctions( +export async function checkInvalidFunctions( collectedFunctions: CollectedFunctions, ): Promise { + await tryToFixNotFoundRoute(collectedFunctions); + if (collectedFunctions.invalidFunctions.size > 0) { await printInvalidFunctionsErrorMessage( collectedFunctions.invalidFunctions, @@ -22,6 +28,49 @@ export async function checkForInvalidFunctions( } } +/** + * Tries to fix potential not-found invalid functions from the Vercel build output. + * + * Static app/not-found.(jsx|tsx) pages generate an _not-found.func serverless function, + * that can be removed as we can fallback to the statically generated 404 page + * + * If the app/not-found.(jsx|tsx) contains runtime logic alongside the _not-found.func serverless + * function also an _error.func will be generated, in such a case we can only warn the user about + * it. + * ( + * That's the only option because: + * - removing the _not-found.func and _error.func doesn't result in a working application + * - we don't have a guarantee that the _error.func hasn't been generated by something else + * and that the _not-found.func is that of a static app/not-found route + * ) + * + * @param collectedFunctions Collected functions from the Vercel build output. + */ +async function tryToFixNotFoundRoute( + collectedFunctions: CollectedFunctions, +): Promise { + const functionsDir = resolve('.vercel', 'output', 'functions'); + const notFoundDir = join(functionsDir, '_not-found.func'); + const errorDir = join(functionsDir, '_error.func'); + + const invalidNotFound = collectedFunctions.invalidFunctions.get(notFoundDir); + const invalidError = collectedFunctions.invalidFunctions.get(errorDir); + + if (invalidNotFound && !invalidError) { + collectedFunctions.invalidFunctions.delete(notFoundDir); + const notFoundRscDir = join(functionsDir, '_not-found.rsc.func'); + collectedFunctions.invalidFunctions.delete(notFoundRscDir); + } + + if (invalidNotFound && invalidError) { + cliWarn(` + Warning: your app/not-found route might contain runtime logic, this is currently + not supported by @cloudflare/next-on-pages, if that's actually the case please + remove the runtime logic from your not-found route + `); + } +} + /** * Prints an error message for the invalid functions from the Vercel build output. *