diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index b6faa2670c4e..1b5ebf5da4c1 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -11,7 +11,6 @@ }, "main": "build/cjs/index.js", "module": "build/esm/index.js", - "browser": "build/esm/client/index.js", "types": "build/types/index.types.d.ts", "publishConfig": { "access": "public" diff --git a/packages/nextjs/src/config/loaders/index.ts b/packages/nextjs/src/config/loaders/index.ts index 322567c1495b..27620e004f39 100644 --- a/packages/nextjs/src/config/loaders/index.ts +++ b/packages/nextjs/src/config/loaders/index.ts @@ -1,3 +1,4 @@ export { default as valueInjectionLoader } from './valueInjectionLoader'; export { default as prefixLoader } from './prefixLoader'; export { default as wrappingLoader } from './wrappingLoader'; +export { default as sdkMultiplexerLoader } from './sdkMultiplexerLoader'; diff --git a/packages/nextjs/src/config/loaders/sdkMultiplexerLoader.ts b/packages/nextjs/src/config/loaders/sdkMultiplexerLoader.ts new file mode 100644 index 000000000000..9241def48a80 --- /dev/null +++ b/packages/nextjs/src/config/loaders/sdkMultiplexerLoader.ts @@ -0,0 +1,24 @@ +import type { LoaderThis } from './types'; + +type LoaderOptions = { + importTarget: string; +}; + +/** + * This loader allows us to multiplex SDKs depending on what is passed to the `importTarget` loader option. + * If this loader encounters a file that contains the string "__SENTRY_SDK_MULTIPLEXER__" it will replace it's entire + * content with an "export all"-statement that points to `importTarget`. + * + * In our case we use this to multiplex different SDKs depending on whether we're bundling browser code, server code, + * or edge-runtime code. + */ +export default function sdkMultiplexerLoader(this: LoaderThis, userCode: string): string { + if (!userCode.includes('__SENTRY_SDK_MULTIPLEXER__')) { + return userCode; + } + + // We know one or the other will be defined, depending on the version of webpack being used + const { importTarget } = 'getOptions' in this ? this.getOptions() : this.query; + + return `export * from "${importTarget}";`; +} diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 35c017e732e4..b5a8d07db2fd 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -85,12 +85,17 @@ export function constructWebpackConfigFunction( // Add a loader which will inject code that sets global values addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions); - if (buildContext.nextRuntime === 'edge') { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] You are using edge functions or middleware. Please note that Sentry does not yet support error monitoring for these features.', - ); - } + newConfig.module.rules.push({ + test: /node_modules\/@sentry\/nextjs/, + use: [ + { + loader: path.resolve(__dirname, 'loaders/sdkMultiplexerLoader.js'), + options: { + importTarget: buildContext.nextRuntime === 'edge' ? './edge' : './client', + }, + }, + ], + }); if (isServer) { if (userSentryOptions.autoInstrumentServerFunctions !== false) { @@ -301,28 +306,25 @@ async function addSentryToEntryProperty( // we know is that it won't have gotten *simpler* in form, so we only need to worry about the object and function // options. See https://webpack.js.org/configuration/entry-context/#entry. - const { isServer, dir: projectDir, dev: isDev } = buildContext; + const { isServer, dir: projectDir, dev: isDev, nextRuntime } = buildContext; const newEntryProperty = typeof currentEntryProperty === 'function' ? await currentEntryProperty() : { ...currentEntryProperty }; // `sentry.server.config.js` or `sentry.client.config.js` (or their TS equivalents) - const userConfigFile = isServer ? getUserConfigFile(projectDir, 'server') : getUserConfigFile(projectDir, 'client'); + const userConfigFile = + nextRuntime === 'edge' + ? getUserConfigFile(projectDir, 'edge') + : isServer + ? getUserConfigFile(projectDir, 'server') + : getUserConfigFile(projectDir, 'client'); // we need to turn the filename into a path so webpack can find it - const filesToInject = [`./${userConfigFile}`]; + const filesToInject = userConfigFile ? [`./${userConfigFile}`] : []; // inject into all entry points which might contain user's code for (const entryPointName in newEntryProperty) { - if ( - shouldAddSentryToEntryPoint( - entryPointName, - isServer, - userSentryOptions.excludeServerRoutes, - isDev, - buildContext.nextRuntime === 'edge', - ) - ) { + if (shouldAddSentryToEntryPoint(entryPointName, isServer, userSentryOptions.excludeServerRoutes, isDev)) { addFilesToExistingEntryPoint(newEntryProperty, entryPointName, filesToInject); } else { if ( @@ -345,10 +347,11 @@ async function addSentryToEntryProperty( * TypeScript or JavaScript file. * * @param projectDir The root directory of the project, where the file should be located - * @param platform Either "server" or "client", so that we know which file to look for - * @returns The name of the relevant file. If no file is found, this method throws an error. + * @param platform Either "server", "client" or "edge", so that we know which file to look for + * @returns The name of the relevant file. If the server or client file is not found, this method throws an error. The + * edge file is optional, if it is not found this function will return `undefined`. */ -export function getUserConfigFile(projectDir: string, platform: 'server' | 'client'): string { +export function getUserConfigFile(projectDir: string, platform: 'server' | 'client' | 'edge'): string | undefined { const possibilities = [`sentry.${platform}.config.ts`, `sentry.${platform}.config.js`]; for (const filename of possibilities) { @@ -357,7 +360,16 @@ export function getUserConfigFile(projectDir: string, platform: 'server' | 'clie } } - throw new Error(`Cannot find '${possibilities[0]}' or '${possibilities[1]}' in '${projectDir}'.`); + // Edge config file is optional + if (platform === 'edge') { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You are using Next.js features that run on the Edge Runtime. Please add a "sentry.edge.config.js" or a "sentry.edge.config.ts" file to your project root in which you initialize the Sentry SDK with "Sentry.init()".', + ); + return; + } else { + throw new Error(`Cannot find '${possibilities[0]}' or '${possibilities[1]}' in '${projectDir}'.`); + } } /** @@ -449,11 +461,9 @@ function shouldAddSentryToEntryPoint( isServer: boolean, excludeServerRoutes: Array = [], isDev: boolean, - isEdgeRuntime: boolean, ): boolean { - // We don't support the Edge runtime yet - if (isEdgeRuntime) { - return false; + if (entryPointName === 'middleware') { + return true; } // On the server side, by default we inject the `Sentry.init()` code into every page (with a few exceptions). @@ -479,9 +489,6 @@ function shouldAddSentryToEntryPoint( // versions.) entryPointRoute === '/_app' || entryPointRoute === '/_document' || - // While middleware was in beta, it could be anywhere (at any level) in the `pages` directory, and would be called - // `_middleware.js`. Until the SDK runs successfully in the lambda edge environment, we have to exclude these. - entryPointName.includes('_middleware') || // Newer versions of nextjs are starting to introduce things outside the `pages/` folder (middleware, an `app/` // directory, etc), but until those features are stable and we know how we want to support them, the safest bet is // not to inject anywhere but inside `pages/`. @@ -552,13 +559,7 @@ export function getWebpackPluginOptions( stripPrefix: ['webpack://_N_E/'], urlPrefix, entries: (entryPointName: string) => - shouldAddSentryToEntryPoint( - entryPointName, - isServer, - userSentryOptions.excludeServerRoutes, - isDev, - buildContext.nextRuntime === 'edge', - ), + shouldAddSentryToEntryPoint(entryPointName, isServer, userSentryOptions.excludeServerRoutes, isDev), release: getSentryRelease(buildId), dryRun: isDev, }); diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 133b6ecf1da0..4133c06089d5 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -1,2 +1,4 @@ export * from './config'; export * from './server'; + +// __SENTRY_SDK_MULTIPLEXER__ diff --git a/packages/nextjs/test/config/fixtures.ts b/packages/nextjs/test/config/fixtures.ts index 8edf88a8caf9..f747edbc2be9 100644 --- a/packages/nextjs/test/config/fixtures.ts +++ b/packages/nextjs/test/config/fixtures.ts @@ -9,6 +9,7 @@ import type { export const SERVER_SDK_CONFIG_FILE = 'sentry.server.config.js'; export const CLIENT_SDK_CONFIG_FILE = 'sentry.client.config.js'; +export const EDGE_SDK_CONFIG_FILE = 'sentry.edge.config.js'; /** Mock next config object */ export const userNextConfig: NextConfigObject = { @@ -43,7 +44,7 @@ export const serverWebpackConfig: WebpackConfigObject = { 'pages/_error': 'private-next-pages/_error.js', 'pages/_app': 'private-next-pages/_app.js', 'pages/sniffTour': ['./node_modules/smellOVision/index.js', 'private-next-pages/sniffTour.js'], - 'pages/api/_middleware': 'private-next-pages/api/_middleware.js', + middleware: 'private-next-pages/middleware.js', 'pages/api/simulator/dogStats/[name]': { import: 'private-next-pages/api/simulator/dogStats/[name].js' }, 'pages/simulator/leaderboard': { import: ['./node_modules/dogPoints/converter.js', 'private-next-pages/simulator/leaderboard.js'], @@ -84,7 +85,7 @@ export const clientWebpackConfig: WebpackConfigObject = { * @returns A mock build context for the given target */ export function getBuildContext( - buildTarget: 'server' | 'client', + buildTarget: 'server' | 'client' | 'edge', materializedNextConfig: ExportedNextConfig, webpackVersion: string = '5.4.15', ): BuildContext { @@ -101,9 +102,11 @@ export function getBuildContext( webpack: { version: webpackVersion }, defaultLoaders: true, totalPages: 2, - isServer: buildTarget === 'server', + isServer: buildTarget === 'server' || buildTarget === 'edge', + nextRuntime: ({ server: 'nodejs', client: undefined, edge: 'edge' } as const)[buildTarget], }; } export const serverBuildContext = getBuildContext('server', exportedNextConfig); export const clientBuildContext = getBuildContext('client', exportedNextConfig); +export const edgeBuildContext = getBuildContext('edge', exportedNextConfig); diff --git a/packages/nextjs/test/config/mocks.ts b/packages/nextjs/test/config/mocks.ts index 581b7d2bbbd1..ddf4ce4d1553 100644 --- a/packages/nextjs/test/config/mocks.ts +++ b/packages/nextjs/test/config/mocks.ts @@ -6,7 +6,7 @@ import * as os from 'os'; import * as path from 'path'; import * as rimraf from 'rimraf'; -import { CLIENT_SDK_CONFIG_FILE, SERVER_SDK_CONFIG_FILE } from './fixtures'; +import { CLIENT_SDK_CONFIG_FILE, EDGE_SDK_CONFIG_FILE, SERVER_SDK_CONFIG_FILE } from './fixtures'; // We use `fs.existsSync()` in `getUserConfigFile()`. When we're not testing `getUserConfigFile()` specifically, all we // need is for it to give us any valid answer, so make it always find what it's looking for. Since this is a core node @@ -14,7 +14,11 @@ import { CLIENT_SDK_CONFIG_FILE, SERVER_SDK_CONFIG_FILE } from './fixtures'; // function also lets us restore the original when we do want to test `getUserConfigFile()`. export const realExistsSync = jest.requireActual('fs').existsSync; export const mockExistsSync = (path: fs.PathLike): ReturnType => { - if ((path as string).endsWith(SERVER_SDK_CONFIG_FILE) || (path as string).endsWith(CLIENT_SDK_CONFIG_FILE)) { + if ( + (path as string).endsWith(SERVER_SDK_CONFIG_FILE) || + (path as string).endsWith(CLIENT_SDK_CONFIG_FILE) || + (path as string).endsWith(EDGE_SDK_CONFIG_FILE) + ) { return true; } diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index 206050d56d38..bee971f104e6 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -6,6 +6,8 @@ import { CLIENT_SDK_CONFIG_FILE, clientBuildContext, clientWebpackConfig, + EDGE_SDK_CONFIG_FILE, + edgeBuildContext, exportedNextConfig, SERVER_SDK_CONFIG_FILE, serverBuildContext, @@ -87,6 +89,7 @@ describe('constructWebpackConfigFunction()', () => { describe('webpack `entry` property config', () => { const serverConfigFilePath = `./${SERVER_SDK_CONFIG_FILE}`; const clientConfigFilePath = `./${CLIENT_SDK_CONFIG_FILE}`; + const edgeConfigFilePath = `./${EDGE_SDK_CONFIG_FILE}`; it('handles various entrypoint shapes', async () => { const finalWebpackConfig = await materializeFinalWebpackConfig({ @@ -207,17 +210,16 @@ describe('constructWebpackConfigFunction()', () => { ); }); - it('does not inject user config file into API middleware', async () => { + it('injects user config file into API middleware', async () => { const finalWebpackConfig = await materializeFinalWebpackConfig({ exportedNextConfig, incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, + incomingWebpackBuildContext: edgeBuildContext, }); expect(finalWebpackConfig.entry).toEqual( expect.objectContaining({ - // no injected file - 'pages/api/_middleware': 'private-next-pages/api/_middleware.js', + middleware: [edgeConfigFilePath, 'private-next-pages/middleware.js'], }), ); });