diff --git a/.changeset/tangy-sides-crash.md b/.changeset/tangy-sides-crash.md new file mode 100644 index 00000000000..318a91808ce --- /dev/null +++ b/.changeset/tangy-sides-crash.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Updates middleware location check to account for proxy.ts in next 16+ applications. diff --git a/integration/tests/middleware-placement.test.ts b/integration/tests/middleware-placement.test.ts index 42bc1b7227f..c756d83374a 100644 --- a/integration/tests/middleware-placement.test.ts +++ b/integration/tests/middleware-placement.test.ts @@ -71,10 +71,16 @@ test.describe('next start - missing middleware @quickstart', () => { }); test('Display error for missing middleware', async ({ page, context }) => { + const { version } = await detectNext(app); + const major = parseSemverMajor(version) ?? 0; const u = createTestUtils({ app, page, context }); await u.page.goToAppHome(); - expect(app.serveOutput).toContain('Your Middleware exists at ./src/middleware.(ts|js)'); + const expectedMessage = + major >= 16 + ? 'Your Middleware exists at ./src/middleware.(ts|js) or proxy.(ts|js)' + : 'Your Middleware exists at ./src/middleware.(ts|js)'; + expect(app.serveOutput).toContain(expectedMessage); }); }); @@ -105,10 +111,16 @@ test.describe('next start - invalid middleware at root on src/ @quickstart', () const u = createTestUtils({ app, page, context }); await u.page.goToAppHome(); - expect(app.serveOutput).not.toContain('Your Middleware exists at ./src/middleware.(ts|js)'); - expect(app.serveOutput).toContain( - 'Clerk: clerkMiddleware() was not run, your middleware file might be misplaced. Move your middleware file to ./src/middleware.ts. Currently located at ./middleware.ts', - ); + const expectedMessage = + major >= 16 + ? 'Your Middleware exists at ./src/middleware.(ts|js) or proxy.(ts|js)' + : 'Your Middleware exists at ./src/middleware.(ts|js)'; + expect(app.serveOutput).not.toContain(expectedMessage); + const expectedError = + major >= 16 + ? 'Clerk: clerkMiddleware() was not run, your middleware or proxy file might be misplaced. Move your middleware or proxy file to ./src/middleware.ts. Currently located at ./middleware.ts' + : 'Clerk: clerkMiddleware() was not run, your middleware file might be misplaced. Move your middleware file to ./src/middleware.ts. Currently located at ./middleware.ts'; + expect(app.serveOutput).toContain(expectedError); }); test('Does not display misplaced middleware error on Next 16+', async ({ page, context }) => { @@ -142,11 +154,19 @@ test.describe('next start - invalid middleware inside app on src/ @quickstart', page, context, }) => { + const { version } = await detectNext(app); + const major = parseSemverMajor(version) ?? 0; const u = createTestUtils({ app, page, context }); await u.page.goToAppHome(); - expect(app.serveOutput).not.toContain('Your Middleware exists at ./src/middleware.(ts|js)'); - expect(app.serveOutput).toContain( - 'Clerk: clerkMiddleware() was not run, your middleware file might be misplaced. Move your middleware file to ./src/middleware.ts. Currently located at ./src/app/middleware.ts', - ); + const expectedMessage = + major >= 16 + ? 'Your Middleware exists at ./src/middleware.(ts|js) or proxy.(ts|js)' + : 'Your Middleware exists at ./src/middleware.(ts|js)'; + expect(app.serveOutput).not.toContain(expectedMessage); + const expectedError = + major >= 16 + ? 'Clerk: clerkMiddleware() was not run, your middleware or proxy file might be misplaced. Move your middleware or proxy file to ./src/middleware.ts. Currently located at ./src/app/middleware.ts' + : 'Clerk: clerkMiddleware() was not run, your middleware file might be misplaced. Move your middleware file to ./src/middleware.ts. Currently located at ./src/app/middleware.ts'; + expect(app.serveOutput).toContain(expectedError); }); }); diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index 3a4f0efd544..2a332e44162 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -11,7 +11,7 @@ import { unauthorized } from '../../server/nextErrors'; import type { AuthProtect } from '../../server/protect'; import { createProtect } from '../../server/protect'; import { decryptClerkRequestData } from '../../server/utils'; -import { isNextWithUnstableServerActions } from '../../utils/sdk-versions'; +import { isNext16OrHigher, isNextWithUnstableServerActions } from '../../utils/sdk-versions'; import { buildRequestLike } from './utils'; /** @@ -81,7 +81,8 @@ export const auth: AuthFn = (async (options?: AuthOptions) => { try { const isSrcAppDir = await import('../../server/fs/middleware-location.js').then(m => m.hasSrcAppDir()); - return [`Your Middleware exists at ./${isSrcAppDir ? 'src/' : ''}middleware.(ts|js)`]; + const fileName = isNext16OrHigher ? 'middleware.(ts|js) or proxy.(ts|js)' : 'middleware.(ts|js)'; + return [`Your Middleware exists at ./${isSrcAppDir ? 'src/' : ''}${fileName}`]; } catch { return []; } diff --git a/packages/nextjs/src/server/fs/middleware-location.ts b/packages/nextjs/src/server/fs/middleware-location.ts index 3586d4a1ae2..67a7393e20d 100644 --- a/packages/nextjs/src/server/fs/middleware-location.ts +++ b/packages/nextjs/src/server/fs/middleware-location.ts @@ -1,3 +1,4 @@ +import { isNext16OrHigher } from '../../utils/sdk-versions'; import { nodeCwdOrThrow, nodeFsOrThrow, nodePathOrThrow } from './utils'; function hasSrcAppDir() { @@ -12,12 +13,17 @@ function hasSrcAppDir() { function suggestMiddlewareLocation() { const fileExtensions = ['ts', 'js'] as const; + // Next.js 16+ supports both middleware.ts (Edge runtime) and proxy.ts (Node.js runtime) + const fileNames = isNext16OrHigher ? ['middleware', 'proxy'] : ['middleware']; + const fileNameDisplay = isNext16OrHigher ? 'middleware or proxy' : 'middleware'; + const suggestionMessage = ( + fileName: string, 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}`; + `Clerk: clerkMiddleware() was not run, your ${fileNameDisplay} file might be misplaced. Move your ${fileNameDisplay} file to ./${to}${fileName}.${extension}. Currently located at ./${from}${fileName}.${extension}`; const { existsSync } = nodeFsOrThrow(); const path = nodePathOrThrow(); @@ -31,9 +37,11 @@ function suggestMiddlewareLocation() { 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); + for (const fileName of fileNames) { + for (const fileExtension of fileExtensions) { + if (existsSync(path.join(basePath, `${fileName}.${fileExtension}`))) { + return suggestionMessage(fileName, fileExtension, to, from); + } } } return undefined; diff --git a/packages/nextjs/src/utils/__tests__/sdk-versions.test.ts b/packages/nextjs/src/utils/__tests__/sdk-versions.test.ts new file mode 100644 index 00000000000..c37be227534 --- /dev/null +++ b/packages/nextjs/src/utils/__tests__/sdk-versions.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('sdk-versions', () => { + beforeEach(() => { + // Clear module cache to allow re-importing with different mocks + vi.resetModules(); + }); + + describe('meetsNextMinimumVersion', () => { + it('should return true when version meets minimum major version', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '16.0.0' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(true); + }); + + it('should return true when version exceeds minimum major version', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '17.0.0' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(true); + }); + + it('should return false when version is below minimum major version', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '15.9.9' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(false); + }); + + it('should return false when version is exactly one below minimum', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '15.0.0' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(false); + }); + + it('should handle patch versions correctly', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '16.5.3' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(true); + }); + + it('should handle beta/prerelease versions correctly', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '16.0.0-beta.1' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(true); + }); + + it('should return false when version is missing', async () => { + vi.doMock('next/package.json', () => ({ + default: {}, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(false); + }); + + it('should return false when version is null', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: null }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(false); + }); + + it('should return false when version is undefined', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: undefined }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(false); + }); + + it('should return false when version is an empty string', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(false); + }); + + it('should return false when version cannot be parsed as a number', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: 'invalid-version' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(false); + }); + + it('should handle single-digit major versions', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '9.0.0' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(false); + }); + + it('should handle double-digit major versions', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '20.0.0' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(true); + }); + + it('should handle version strings with leading zeros', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '016.0.0' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(true); + }); + }); + + describe('isNext16OrHigher', () => { + it('should be a boolean value', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '16.0.0' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(typeof isNext16OrHigher).toBe('boolean'); + }); + + it('should correctly identify Next.js 16', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '16.0.0' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(true); + }); + + it('should correctly identify Next.js 15 as not 16+', async () => { + vi.doMock('next/package.json', () => ({ + default: { version: '15.2.3' }, + })); + + const { isNext16OrHigher } = await import('../sdk-versions.js'); + expect(isNext16OrHigher).toBe(false); + }); + }); +}); diff --git a/packages/nextjs/src/utils/sdk-versions.ts b/packages/nextjs/src/utils/sdk-versions.ts index 729a096a6f9..2dc49c1698e 100644 --- a/packages/nextjs/src/utils/sdk-versions.ts +++ b/packages/nextjs/src/utils/sdk-versions.ts @@ -1,11 +1,25 @@ import nextPkg from 'next/package.json'; -const isNext13 = nextPkg.version.startsWith('13.'); +function meetsNextMinimumVersion(minimumMajorVersion: number) { + if (!nextPkg?.version) { + return false; + } + + const majorVersion = parseInt(nextPkg.version.split('.')[0], 10); + return !isNaN(majorVersion) && majorVersion >= minimumMajorVersion; +} + +const isNext13 = nextPkg?.version?.startsWith('13.') ?? false; /** * Those versions are affected by a bundling issue that will break the application if `node:fs` is used inside a server function. * The affected versions are >=next@13.5.4 and <=next@14.0.4 */ -const isNextWithUnstableServerActions = isNext13 || nextPkg.version.startsWith('14.0'); +const isNextWithUnstableServerActions = isNext13 || (nextPkg?.version?.startsWith('14.0') ?? false); + +/** + * Next.js 16+ renamed middleware.ts to proxy.ts + */ +const isNext16OrHigher = meetsNextMinimumVersion(16); -export { isNext13, isNextWithUnstableServerActions }; +export { isNext13, isNextWithUnstableServerActions, isNext16OrHigher };