diff --git a/.changeset/orange-clouds-relax.md b/.changeset/orange-clouds-relax.md deleted file mode 100644 index 2f30b96fdb6..00000000000 --- a/.changeset/orange-clouds-relax.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/nextjs': patch ---- - -Revert: Improve error messages when clerkMiddleware is missing by suggesting the correct path to place the middleware.ts file (#4979). diff --git a/.changeset/weak-phones-retire.md b/.changeset/weak-phones-retire.md new file mode 100644 index 00000000000..a5ccb2f22f9 --- /dev/null +++ b/.changeset/weak-phones-retire.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Bug fix: Remove warning for accessing Node APIs when running `next build` with `clerkMiddleware` imported. diff --git a/packages/nextjs/src/app-router/keyless-actions.ts b/packages/nextjs/src/app-router/keyless-actions.ts index 920751126c1..d906b55c0ff 100644 --- a/packages/nextjs/src/app-router/keyless-actions.ts +++ b/packages/nextjs/src/app-router/keyless-actions.ts @@ -72,6 +72,6 @@ export async function deleteKeylessAction() { return; } - await import('../server/keyless-node.js').then(m => m.removeKeyless()); + await import('../server/keyless-node.js').then(m => m.removeKeyless()).catch(() => {}); return; } diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index fdfab20cb7d..690a1dc5e04 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -8,6 +8,7 @@ import { createClerkClientWithOptions } from '../../server/createClerkClient'; import type { NextClerkProviderProps } from '../../types'; import { canUseKeyless } from '../../utils/feature-flags'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; +import { onlyTry } from '../../utils/only-try'; import { isNext13 } from '../../utils/sdk-versions'; import { ClientClerkProvider } from '../client/ClerkProvider'; import { deleteKeylessAction } from '../keyless-actions'; @@ -24,15 +25,6 @@ const getNonceFromCSPHeader = React.cache(async function getNonceFromCSPHeader() return getScriptNonceFromHeader((await headers()).get('Content-Security-Policy') || '') || ''; }); -/** Discards errors thrown by attempted code */ -const onlyTry = (cb: () => unknown) => { - try { - cb(); - } catch { - // ignore - } -}; - export async function ClerkProvider( props: Without, ) { diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index b46a003c500..eddee32a647 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -3,12 +3,13 @@ import { constants, createClerkRequest, createRedirect, type RedirectFun } from import { notFound, redirect } from 'next/navigation'; import { PUBLISHABLE_KEY, SIGN_IN_URL, SIGN_UP_URL } from '../../server/constants'; -import { createGetAuth } from '../../server/createGetAuth'; +import { createAsyncGetAuth } from '../../server/createGetAuth'; import { authAuthHeaderMissing } from '../../server/errors'; import { getAuthKeyFromRequest, getHeader } from '../../server/headers-utils'; import type { AuthProtect } from '../../server/protect'; import { createProtect } from '../../server/protect'; import { decryptClerkRequestData } from '../../server/utils'; +import { isNextWithUnstableServerActions } from '../../utils/sdk-versions'; import { buildRequestLike } from './utils'; /** @@ -25,8 +26,10 @@ type Auth = AuthObject & { */ redirectToSignIn: RedirectFun>; }; + export interface AuthFn { (): Promise; + /** * `auth` includes a single property, the `protect()` method, which you can use in two ways: * - to check if a user is authenticated (signed in) @@ -60,9 +63,22 @@ export const auth: AuthFn = async () => { require('server-only'); const request = await buildRequestLike(); - const authObject = createGetAuth({ + + const stepsBasedOnSrcDirectory = async () => { + if (isNextWithUnstableServerActions) { + return []; + } + + try { + const isSrcAppDir = await import('../../server/fs/middleware-location.js').then(m => m.hasSrcAppDir()); + return [`Your Middleware exists at ./${isSrcAppDir ? 'src/' : ''}middleware.(ts|js)`]; + } catch { + return []; + } + }; + const authObject = await createAsyncGetAuth({ debugLoggerName: 'auth()', - noAuthStatusMessage: authAuthHeaderMissing(), + noAuthStatusMessage: authAuthHeaderMissing('auth', await stepsBasedOnSrcDirectory()), })(request); const clerkUrl = getAuthKeyFromRequest(request, 'ClerkUrl'); diff --git a/packages/nextjs/src/app-router/server/utils.ts b/packages/nextjs/src/app-router/server/utils.ts index 490166d3027..1c6e24a38f4 100644 --- a/packages/nextjs/src/app-router/server/utils.ts +++ b/packages/nextjs/src/app-router/server/utils.ts @@ -34,7 +34,7 @@ export async function buildRequestLike(): Promise { } throw new Error( - `Clerk: auth() and currentUser() are only supported in App Router (/app directory).\nIf you're using /pages, try getAuth() instead.\nOriginal error: ${e}`, + `Clerk: auth(), currentUser() and clerkClient(), are only supported in App Router (/app directory).\nIf you're using /pages, try getAuth() instead.\nOriginal error: ${e}`, ); } } diff --git a/packages/nextjs/src/runtime/browser/safe-node-apis.js b/packages/nextjs/src/runtime/browser/safe-node-apis.js index 7cad32e4d0f..d9bc5ba5b97 100644 --- a/packages/nextjs/src/runtime/browser/safe-node-apis.js +++ b/packages/nextjs/src/runtime/browser/safe-node-apis.js @@ -3,5 +3,6 @@ */ const fs = undefined; const path = undefined; +const cwd = undefined; -module.exports = { fs, path }; +module.exports = { fs, path, cwd }; diff --git a/packages/nextjs/src/runtime/node/safe-node-apis.js b/packages/nextjs/src/runtime/node/safe-node-apis.js index 5be87334100..0e074a8191e 100644 --- a/packages/nextjs/src/runtime/node/safe-node-apis.js +++ b/packages/nextjs/src/runtime/node/safe-node-apis.js @@ -14,4 +14,6 @@ const fs = { rmSync, }; -module.exports = { fs, path }; +const cwd = () => process.cwd(); + +module.exports = { fs, path, cwd }; diff --git a/packages/nextjs/src/server/__tests__/createGetAuth.test.ts b/packages/nextjs/src/server/__tests__/createGetAuth.test.ts index e881ab7368b..56a9e99bd12 100644 --- a/packages/nextjs/src/server/__tests__/createGetAuth.test.ts +++ b/packages/nextjs/src/server/__tests__/createGetAuth.test.ts @@ -3,7 +3,7 @@ import hmacSHA1 from 'crypto-js/hmac-sha1'; import { NextRequest } from 'next/server'; import { describe, expect, it } from 'vitest'; -import { createGetAuth, getAuth } from '../createGetAuth'; +import { createSyncGetAuth, getAuth } from '../createGetAuth'; const mockSecretKey = 'sk_test_mock'; @@ -16,7 +16,7 @@ const mockTokenSignature = hmacSHA1(mockToken, 'sk_test_mock').toString(); describe('createGetAuth(opts)', () => { it('returns a getAuth function', () => { - expect(createGetAuth({ debugLoggerName: 'test', noAuthStatusMessage: 'test' })).toBeInstanceOf(Function); + expect(createSyncGetAuth({ debugLoggerName: 'test', noAuthStatusMessage: 'test' })).toBeInstanceOf(Function); }); }); diff --git a/packages/nextjs/src/server/createGetAuth.ts b/packages/nextjs/src/server/createGetAuth.ts index f95ea6871b7..07225435f97 100644 --- a/packages/nextjs/src/server/createGetAuth.ts +++ b/packages/nextjs/src/server/createGetAuth.ts @@ -3,15 +3,60 @@ import { constants } from '@clerk/backend/internal'; import { isTruthy } from '@clerk/shared/underscore'; import { withLogger } from '../utils/debugLogger'; +import { isNextWithUnstableServerActions } from '../utils/sdk-versions'; import { getAuthDataFromRequest } from './data/getAuthDataFromRequest'; import { getAuthAuthHeaderMissing } from './errors'; -import { getHeader } from './headers-utils'; +import { detectClerkMiddleware, getHeader } from './headers-utils'; import type { RequestLike } from './types'; import { assertAuthStatus } from './utils'; -export const createGetAuth = ({ +/** + * The async variant of our old `createGetAuth` allows for asynchronous code inside its callback. + * Should be used with function like `auth()` that are already asynchronous. + */ +export const createAsyncGetAuth = ({ + debugLoggerName, noAuthStatusMessage, +}: { + debugLoggerName: string; + noAuthStatusMessage: string; +}) => + withLogger(debugLoggerName, logger => { + return async (req: RequestLike, opts?: { secretKey?: string }): Promise => { + if (isTruthy(getHeader(req, constants.Headers.EnableDebug))) { + logger.enable(); + } + + if (!detectClerkMiddleware(req)) { + // Keep the same behaviour for versions that may have issues with bundling `node:fs` + if (isNextWithUnstableServerActions) { + assertAuthStatus(req, noAuthStatusMessage); + } + + const missConfiguredMiddlewareLocation = await import('./fs/middleware-location.js') + .then(m => m.suggestMiddlewareLocation()) + .catch(() => undefined); + + if (missConfiguredMiddlewareLocation) { + throw new Error(missConfiguredMiddlewareLocation); + } + + // still throw there is no suggested move location + assertAuthStatus(req, noAuthStatusMessage); + } + + return getAuthDataFromRequest(req, { ...opts, logger }); + }; + }); + +/** + * Previous known as `createGetAuth`. We needed to create a sync and async variant in order to allow for improvements + * that required dynamic imports (using `require` would not work). + * It powers the synchronous top-level api `getAuh()`. + */ +export const createSyncGetAuth = ({ debugLoggerName, + noAuthStatusMessage, }: { debugLoggerName: string; noAuthStatusMessage: string; @@ -23,7 +68,6 @@ export const createGetAuth = ({ } assertAuthStatus(req, noAuthStatusMessage); - return getAuthDataFromRequest(req, { ...opts, logger }); }; }); @@ -107,7 +151,7 @@ export const createGetAuth = ({ * } * ``` */ -export const getAuth = createGetAuth({ +export const getAuth = createSyncGetAuth({ debugLoggerName: 'getAuth()', noAuthStatusMessage: getAuthAuthHeaderMissing(), }); diff --git a/packages/nextjs/src/server/errors.ts b/packages/nextjs/src/server/errors.ts index 0bd1e7be064..24235da292b 100644 --- a/packages/nextjs/src/server/errors.ts +++ b/packages/nextjs/src/server/errors.ts @@ -20,9 +20,9 @@ Check if signInUrl is missing from your configuration or if it is not an absolut export const getAuthAuthHeaderMissing = () => authAuthHeaderMissing('getAuth'); -export const authAuthHeaderMissing = (helperName = 'auth') => +export const authAuthHeaderMissing = (helperName = 'auth', prefixSteps?: string[]) => `Clerk: ${helperName}() was called but Clerk can't detect usage of clerkMiddleware(). Please ensure the following: -- clerkMiddleware() is used in your Next.js Middleware. +- ${prefixSteps ? [...prefixSteps, ''].join('\n- ') : ' '}clerkMiddleware() is used in your Next.js Middleware. - Your Middleware matcher is configured to match this route or page. - If you are using the src directory, make sure the Middleware file is inside of it. diff --git a/packages/nextjs/src/server/fs/middleware-location.ts b/packages/nextjs/src/server/fs/middleware-location.ts new file mode 100644 index 00000000000..3586d4a1ae2 --- /dev/null +++ b/packages/nextjs/src/server/fs/middleware-location.ts @@ -0,0 +1,55 @@ +import { nodeCwdOrThrow, nodeFsOrThrow, nodePathOrThrow } from './utils'; + +function hasSrcAppDir() { + const { existsSync } = nodeFsOrThrow(); + const path = nodePathOrThrow(); + const cwd = nodeCwdOrThrow(); + + const projectWithAppSrc = path.join(cwd(), 'src', 'app'); + + return !!existsSync(projectWithAppSrc); +} + +function suggestMiddlewareLocation() { + const fileExtensions = ['ts', 'js'] as const; + const suggestionMessage = ( + extension: (typeof fileExtensions)[number], + to: 'src/' | '', + from: 'src/app/' | 'app/' | '', + ) => + `Clerk: clerkMiddleware() was not run, your middleware file might be misplaced. Move your middleware file to ./${to}middleware.${extension}. Currently located at ./${from}middleware.${extension}`; + + const { existsSync } = nodeFsOrThrow(); + const path = nodePathOrThrow(); + const cwd = nodeCwdOrThrow(); + + const projectWithAppSrcPath = path.join(cwd(), 'src', 'app'); + const projectWithAppPath = path.join(cwd(), 'app'); + + const checkMiddlewareLocation = ( + basePath: string, + to: 'src/' | '', + from: 'src/app/' | 'app/' | '', + ): string | undefined => { + for (const fileExtension of fileExtensions) { + if (existsSync(path.join(basePath, `middleware.${fileExtension}`))) { + return suggestionMessage(fileExtension, to, from); + } + } + return undefined; + }; + + if (existsSync(projectWithAppSrcPath)) { + return ( + checkMiddlewareLocation(projectWithAppSrcPath, 'src/', 'src/app/') || checkMiddlewareLocation(cwd(), 'src/', '') + ); + } + + if (existsSync(projectWithAppPath)) { + return checkMiddlewareLocation(projectWithAppPath, '', 'app/'); + } + + return undefined; +} + +export { suggestMiddlewareLocation, hasSrcAppDir }; diff --git a/packages/nextjs/src/server/fs/utils.ts b/packages/nextjs/src/server/fs/utils.ts new file mode 100644 index 00000000000..4150a4d39ea --- /dev/null +++ b/packages/nextjs/src/server/fs/utils.ts @@ -0,0 +1,33 @@ +/** + * Attention: Only import this module when the node runtime is used. + * We are using conditional imports to mitigate bundling issues with Next.js server actions on version prior to 14.1.0. + */ +// @ts-ignore +import nodeRuntime from '#safe-node-apis'; + +const throwMissingFsModule = (module: string) => { + throw new Error(`Clerk: ${module} is missing. This is an internal error. Please contact Clerk's support.`); +}; + +const nodeFsOrThrow = () => { + if (!nodeRuntime.fs) { + throwMissingFsModule('fs'); + } + return nodeRuntime.fs; +}; + +const nodePathOrThrow = () => { + if (!nodeRuntime.path) { + throwMissingFsModule('path'); + } + return nodeRuntime.path; +}; + +const nodeCwdOrThrow = () => { + if (!nodeRuntime.cwd) { + throwMissingFsModule('cwd'); + } + return nodeRuntime.cwd; +}; + +export { nodeCwdOrThrow, nodeFsOrThrow, nodePathOrThrow }; diff --git a/packages/nextjs/src/server/keyless-node.ts b/packages/nextjs/src/server/keyless-node.ts index 5de03b2be55..347a70c57c3 100644 --- a/packages/nextjs/src/server/keyless-node.ts +++ b/packages/nextjs/src/server/keyless-node.ts @@ -1,13 +1,7 @@ import type { AccountlessApplication } from '@clerk/backend'; -/** - * Attention: Only import this module when the node runtime is used. - * We are using conditional imports to mitigate bundling issues with Next.js server actions on version prior to 14.1.0. - */ -// @ts-ignore -import nodeRuntime from '#safe-node-apis'; - import { createClerkClientWithOptions } from './createClerkClient'; +import { nodeCwdOrThrow, nodeFsOrThrow, nodePathOrThrow } from './fs/utils'; /** * The Clerk-specific directory name. @@ -20,33 +14,16 @@ const CLERK_HIDDEN = '.clerk'; */ const CLERK_LOCK = 'clerk.lock'; -const throwMissingFsModule = () => { - throw new Error("Clerk: fsModule.fs is missing. This is an internal error. Please contact Clerk's support."); -}; - -const safeNodeRuntimeFs = () => { - if (!nodeRuntime.fs) { - throwMissingFsModule(); - } - return nodeRuntime.fs; -}; - -const safeNodeRuntimePath = () => { - if (!nodeRuntime.path) { - throwMissingFsModule(); - } - return nodeRuntime.path; -}; - /** * The `.clerk/` directory is NOT safe to be committed as it may include sensitive information about a Clerk instance. * It may include an instance's secret key and the secret token for claiming that instance. */ function updateGitignore() { - const { existsSync, writeFileSync, readFileSync, appendFileSync } = safeNodeRuntimeFs(); + const { existsSync, writeFileSync, readFileSync, appendFileSync } = nodeFsOrThrow(); - const path = safeNodeRuntimePath(); - const gitignorePath = path.join(process.cwd(), '.gitignore'); + const path = nodePathOrThrow(); + const cwd = nodeCwdOrThrow(); + const gitignorePath = path.join(cwd(), '.gitignore'); if (!existsSync(gitignorePath)) { writeFileSync(gitignorePath, ''); } @@ -60,8 +37,9 @@ function updateGitignore() { } const generatePath = (...slugs: string[]) => { - const path = safeNodeRuntimePath(); - return path.join(process.cwd(), CLERK_HIDDEN, ...slugs); + const path = nodePathOrThrow(); + const cwd = nodeCwdOrThrow(); + return path.join(cwd(), CLERK_HIDDEN, ...slugs); }; const _TEMP_DIR_NAME = '.tmp'; @@ -71,7 +49,7 @@ const getKeylessReadMePath = () => generatePath(_TEMP_DIR_NAME, 'README.md'); let isCreatingFile = false; export function safeParseClerkFile(): AccountlessApplication | undefined { - const { readFileSync } = safeNodeRuntimeFs(); + const { readFileSync } = nodeFsOrThrow(); try { const CONFIG_PATH = getKeylessConfigurationPath(); let fileAsString; @@ -90,7 +68,7 @@ export function safeParseClerkFile(): AccountlessApplication | undefined { * Using both an in-memory and file system lock seems to be the most effective solution. */ const lockFileWriting = () => { - const { writeFileSync } = safeNodeRuntimeFs(); + const { writeFileSync } = nodeFsOrThrow(); isCreatingFile = true; @@ -107,7 +85,7 @@ const lockFileWriting = () => { }; const unlockFileWriting = () => { - const { rmSync } = safeNodeRuntimeFs(); + const { rmSync } = nodeFsOrThrow(); try { rmSync(CLERK_LOCK, { force: true, recursive: true }); @@ -119,12 +97,12 @@ const unlockFileWriting = () => { }; const isFileWritingLocked = () => { - const { existsSync } = safeNodeRuntimeFs(); + const { existsSync } = nodeFsOrThrow(); return isCreatingFile || existsSync(CLERK_LOCK); }; async function createOrReadKeyless(): Promise { - const { writeFileSync, mkdirSync } = safeNodeRuntimeFs(); + const { writeFileSync, mkdirSync } = nodeFsOrThrow(); /** * If another request is already in the process of acquiring keys return early. @@ -188,7 +166,7 @@ This directory is auto-generated from \`@clerk/nextjs\` because you are running } function removeKeyless() { - const { rmSync } = safeNodeRuntimeFs(); + const { rmSync } = nodeFsOrThrow(); /** * If another request is already in the process of acquiring keys return early. diff --git a/packages/nextjs/src/utils/only-try.ts b/packages/nextjs/src/utils/only-try.ts new file mode 100644 index 00000000000..5f8c62e87f8 --- /dev/null +++ b/packages/nextjs/src/utils/only-try.ts @@ -0,0 +1,12 @@ +/** + * Discards errors thrown by attempted code + */ +const onlyTry = (cb: () => unknown) => { + try { + cb(); + } catch { + // ignore + } +}; + +export { onlyTry };