diff --git a/.changeset/fuzzy-rockets-smash.md b/.changeset/fuzzy-rockets-smash.md new file mode 100644 index 00000000000..9fa92bda846 --- /dev/null +++ b/.changeset/fuzzy-rockets-smash.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Improve error messages when `clerkMiddleware` is missing by suggesting the correct path to place the `middleware.ts` file. diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index 30c555792d8..02e039fdc76 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -7,6 +7,7 @@ import { getDynamicAuthData } from '../../server/buildClerkProps'; 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'; @@ -23,15 +24,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..e090b849adb 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/keyless-node.js').then(m => m.hasSrcAppDir()); + return [`Your Middleware exists at ./${isSrcAppDir ? 'src/' : ''}middleware.ts`]; + } 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/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..7e6caeb0f12 100644 --- a/packages/nextjs/src/server/createGetAuth.ts +++ b/packages/nextjs/src/server/createGetAuth.ts @@ -1,29 +1,63 @@ -import type { AuthObject } from '@clerk/backend'; 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 = ({ +export const createAsyncGetAuth = ({ + debugLoggerName, noAuthStatusMessage, +}: { + debugLoggerName: string; + noAuthStatusMessage: string; +}) => + withLogger(debugLoggerName, logger => { + return async (req: RequestLike, opts?: { secretKey?: string }) => { + 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('./keyless-node.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 }); + }; + }); + +export const createSyncGetAuth = ({ debugLoggerName, + noAuthStatusMessage, }: { debugLoggerName: string; noAuthStatusMessage: string; }) => withLogger(debugLoggerName, logger => { - return (req: RequestLike, opts?: { secretKey?: string }): AuthObject => { + return (req: RequestLike, opts?: { secretKey?: string }) => { if (isTruthy(getHeader(req, constants.Headers.EnableDebug))) { logger.enable(); } assertAuthStatus(req, noAuthStatusMessage); - return getAuthDataFromRequest(req, { ...opts, logger }); }; }); @@ -107,7 +141,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/keyless-node.ts b/packages/nextjs/src/server/keyless-node.ts index a72b9de50cc..f83f88028f2 100644 --- a/packages/nextjs/src/server/keyless-node.ts +++ b/packages/nextjs/src/server/keyless-node.ts @@ -209,4 +209,47 @@ function removeKeyless() { unlockFileWriting(); } -export { createOrReadKeyless, removeKeyless }; +function hasSrcAppDir() { + const { existsSync } = safeNodeRuntimeFs(); + const path = safeNodeRuntimePath(); + + const projectWithAppSrc = path.join(process.cwd(), 'src', 'app'); + + return !!existsSync(projectWithAppSrc); +} + +function suggestMiddlewareLocation() { + const suggestionMessage = (to?: 'src/', from?: 'src/app/' | 'app/') => + `Clerk: Move your middleware file to ./${to || ''}middleware.ts. Currently located at ./${from || ''}middleware.ts`; + + const { existsSync } = safeNodeRuntimeFs(); + const path = safeNodeRuntimePath(); + + const projectWithAppSrcPath = path.join(process.cwd(), 'src', 'app'); + const projectWithAppPath = path.join(process.cwd(), 'app'); + + if (existsSync(projectWithAppSrcPath)) { + if (existsSync(path.join(projectWithAppSrcPath, 'middleware.ts'))) { + return suggestionMessage('src/', 'src/app/'); + } + + if (existsSync(path.join(process.cwd(), 'middleware.ts'))) { + return suggestionMessage('src/'); + } + + // default error + return undefined; + } + + if (existsSync(projectWithAppPath)) { + if (existsSync(path.join(projectWithAppPath, 'middleware.ts'))) { + return suggestionMessage(undefined, 'app/'); + } + // default error + return undefined; + } + + return undefined; +} + +export { createOrReadKeyless, removeKeyless, suggestMiddlewareLocation, hasSrcAppDir }; 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 };