Skip to content

Commit 967029b

Browse files
committed
feat(nextjs): remove pages API wrapper but keep edge wrapping
1 parent edc5643 commit 967029b

File tree

6 files changed

+86
-125
lines changed

6 files changed

+86
-125
lines changed

packages/nextjs/rollup.npm.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export default [
4040
...makeNPMConfigVariants(
4141
makeBaseNPMConfig({
4242
entrypoints: [
43-
'src/config/templates/apiWrapperTemplate.ts',
43+
'src/config/templates/edgeApiWrapperTemplate.ts',
4444
'src/config/templates/middlewareWrapperTemplate.ts',
4545
'src/config/templates/pageWrapperTemplate.ts',
4646
'src/config/templates/requestAsyncStorageShim.ts',

packages/nextjs/src/config/loaders/wrappingLoader.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as fs from 'fs';
44
import * as path from 'path';
55
import type { RollupBuild, RollupError } from 'rollup';
66
import { rollup } from 'rollup';
7-
import type { ServerComponentContext, VercelCronsConfig } from '../../common/types';
7+
import type { ServerComponentContext } from '../../common/types';
88
import type { LoaderThis } from './types';
99

1010
// Just a simple placeholder to make referencing module consistent
@@ -13,12 +13,12 @@ const SENTRY_WRAPPER_MODULE_NAME = 'sentry-wrapper-module';
1313
// Needs to end in .cjs in order for the `commonjs` plugin to pick it up
1414
const WRAPPING_TARGET_MODULE_NAME = '__SENTRY_WRAPPING_TARGET_FILE__.cjs';
1515

16-
const apiWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'apiWrapperTemplate.js');
17-
const apiWrapperTemplateCode = fs.readFileSync(apiWrapperTemplatePath, { encoding: 'utf8' });
18-
1916
const pageWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'pageWrapperTemplate.js');
2017
const pageWrapperTemplateCode = fs.readFileSync(pageWrapperTemplatePath, { encoding: 'utf8' });
2118

19+
const edgeApiWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'edgeApiWrapperTemplate.js');
20+
const edgeApiWrapperTemplateCode = fs.readFileSync(edgeApiWrapperTemplatePath, { encoding: 'utf8' });
21+
2222
const middlewareWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'middlewareWrapperTemplate.js');
2323
const middlewareWrapperTemplateCode = fs.readFileSync(middlewareWrapperTemplatePath, { encoding: 'utf8' });
2424

@@ -40,17 +40,15 @@ export type WrappingLoaderOptions = {
4040
appDir: string | undefined;
4141
pageExtensionRegex: string;
4242
excludeServerRoutes: Array<RegExp | string>;
43-
wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'route-handler';
44-
vercelCronsConfig?: VercelCronsConfig;
43+
wrappingTargetKind: 'page' | 'edge-api-route' | 'middleware' | 'server-component' | 'route-handler';
4544
nextjsRequestAsyncStorageModulePath?: string;
4645
};
4746

4847
/**
4948
* Replace the loaded file with a wrapped version the original file. In the wrapped version, the original file is loaded,
50-
* any data-fetching functions (`getInitialProps`, `getStaticProps`, and `getServerSideProps`) or API routes it contains
49+
* any data-fetching functions (`getInitialProps`, `getStaticProps`, and `getServerSideProps`) it contains
5150
* are wrapped, and then everything is re-exported.
5251
*/
53-
// eslint-disable-next-line complexity
5452
export default function wrappingLoader(
5553
this: LoaderThis<WrappingLoaderOptions>,
5654
userCode: string,
@@ -64,15 +62,14 @@ export default function wrappingLoader(
6462
pageExtensionRegex,
6563
excludeServerRoutes = [],
6664
wrappingTargetKind,
67-
vercelCronsConfig,
6865
nextjsRequestAsyncStorageModulePath,
6966
} = 'getOptions' in this ? this.getOptions() : this.query;
7067

7168
this.async();
7269

7370
let templateCode: string;
7471

75-
if (wrappingTargetKind === 'page' || wrappingTargetKind === 'api-route') {
72+
if (wrappingTargetKind === 'page') {
7673
if (pagesDir === undefined) {
7774
this.callback(null, userCode, userModuleSourceMap);
7875
return;
@@ -102,15 +99,41 @@ export default function wrappingLoader(
10299
return;
103100
}
104101

105-
if (wrappingTargetKind === 'page') {
106-
templateCode = pageWrapperTemplateCode;
107-
} else if (wrappingTargetKind === 'api-route') {
108-
templateCode = apiWrapperTemplateCode;
109-
} else {
110-
throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`);
102+
templateCode = pageWrapperTemplateCode;
103+
104+
// Inject the route and the path to the file we're wrapping into the template
105+
templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\'));
106+
} else if (wrappingTargetKind === 'edge-api-route') {
107+
if (pagesDir === undefined) {
108+
this.callback(null, userCode, userModuleSourceMap);
109+
return;
110+
}
111+
112+
// Get the parameterized route name from this API route's filepath
113+
const parameterizedPagesRoute = path
114+
// Get the path of the file inside of the pages directory
115+
.relative(pagesDir, this.resourcePath)
116+
// Replace all backslashes with forward slashes (windows)
117+
.replace(/\\/g, '/')
118+
// Add a slash at the beginning
119+
.replace(/(.*)/, '/$1')
120+
// Pull off the file extension
121+
// eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- not end user input
122+
.replace(new RegExp(`\\.(${pageExtensionRegex})`), '')
123+
// Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into
124+
// just `/xyz`
125+
.replace(/\/index$/, '')
126+
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
127+
// homepage), sub back in the root route
128+
.replace(/^$/, '/');
129+
130+
// Skip explicitly-ignored pages
131+
if (stringMatchesSomePattern(parameterizedPagesRoute, excludeServerRoutes, true)) {
132+
this.callback(null, userCode, userModuleSourceMap);
133+
return;
111134
}
112135

113-
templateCode = templateCode.replace(/__VERCEL_CRONS_CONFIGURATION__/g, JSON.stringify(vercelCronsConfig));
136+
templateCode = edgeApiWrapperTemplateCode;
114137

115138
// Inject the route and the path to the file we're wrapping into the template
116139
templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\'));

packages/nextjs/src/config/templates/apiWrapperTemplate.ts renamed to packages/nextjs/src/config/templates/edgeApiWrapperTemplate.ts

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
2-
* This file is a template for the code which will be substituted when our webpack loader handles API files in the
3-
* `pages/` directory.
2+
* This file is a template for the code which will be substituted when our webpack loader handles edge API files in the
3+
* `pages/api/` directory.
44
*
55
* We use `__SENTRY_WRAPPING_TARGET_FILE__` as a placeholder for the path to the file being wrapped. Because it's not a real package,
66
* this causes both TS and ESLint to complain, hence the pragma comments below.
@@ -10,15 +10,15 @@
1010
import * as origModule from '__SENTRY_WRAPPING_TARGET_FILE__';
1111
import * as Sentry from '@sentry/nextjs';
1212
import type { PageConfig } from 'next';
13-
import type { NextApiHandler, VercelCronsConfig } from '../../common/types';
13+
import type { EdgeRouteHandler } from '../../edge/types';
1414

1515
type NextApiModule = (
1616
| {
1717
// ESM export
18-
default?: NextApiHandler;
18+
default?: EdgeRouteHandler;
1919
}
2020
// CJS export
21-
| NextApiHandler
21+
| EdgeRouteHandler
2222
) & { config?: PageConfig };
2323

2424
const userApiModule = origModule as NextApiModule;
@@ -37,26 +37,11 @@ if ('default' in userApiModule && typeof userApiModule.default === 'function') {
3737

3838
const origConfig = userApiModule.config || {};
3939

40-
// Setting `externalResolver` to `true` prevents nextjs from throwing a warning in dev about API routes resolving
41-
// without sending a response. It's a false positive (a response is sent, but only after we flush our send queue), and
42-
// we throw a warning of our own to tell folks that, but it's better if we just don't have to deal with it in the first
43-
// place.
44-
export const config = {
45-
...origConfig,
46-
api: {
47-
...origConfig.api,
48-
externalResolver: true,
49-
},
50-
};
51-
52-
declare const __VERCEL_CRONS_CONFIGURATION__: VercelCronsConfig;
40+
// Re-export the config as-is (edge routes don't need externalResolver)
41+
export const config = origConfig;
5342

5443
let wrappedHandler = userProvidedHandler;
5544

56-
if (wrappedHandler && __VERCEL_CRONS_CONFIGURATION__) {
57-
wrappedHandler = Sentry.wrapApiHandlerWithSentryVercelCrons(wrappedHandler, __VERCEL_CRONS_CONFIGURATION__);
58-
}
59-
6045
if (wrappedHandler) {
6146
wrappedHandler = Sentry.wrapApiHandlerWithSentry(wrappedHandler, '__ROUTE__');
6247
}
@@ -67,3 +52,4 @@ export default wrappedHandler;
6752
// not include anything whose name matches something we've explicitly exported above.
6853
// @ts-expect-error See above
6954
export * from '__SENTRY_WRAPPING_TARGET_FILE__';
55+

packages/nextjs/src/config/webpack.ts

Lines changed: 33 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { debug, escapeStringForRegex, loadModule, parseSemver } from '@sentry/co
55
import * as fs from 'fs';
66
import * as path from 'path';
77
import { sync as resolveSync } from 'resolve';
8-
import type { VercelCronsConfig } from '../common/types';
98
import { getBuildPluginOptions, normalizePathForGlob } from './getBuildPluginOptions';
109
import type { RouteManifest } from './manifest/types';
1110
// Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our
@@ -133,8 +132,6 @@ export function constructWebpackConfigFunction({
133132
appDirPath = maybeSrcAppDirPath;
134133
}
135134

136-
const apiRoutesPath = pagesDirPath ? path.join(pagesDirPath, 'api') : undefined;
137-
138135
const middlewareLocationFolder = pagesDirPath
139136
? path.join(pagesDirPath, '..')
140137
: appDirPath
@@ -166,20 +163,44 @@ export function constructWebpackConfigFunction({
166163

167164
const isPageResource = (resourcePath: string): boolean => {
168165
const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath);
166+
const apiRoutesPath = pagesDirPath ? path.join(pagesDirPath, 'api') : undefined;
169167
return (
170168
pagesDirPath !== undefined &&
171169
normalizedAbsoluteResourcePath.startsWith(pagesDirPath + path.sep) &&
172-
!normalizedAbsoluteResourcePath.startsWith(apiRoutesPath + path.sep) &&
170+
!(apiRoutesPath && normalizedAbsoluteResourcePath.startsWith(apiRoutesPath + path.sep)) &&
173171
dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext))
174172
);
175173
};
176174

177-
const isApiRouteResource = (resourcePath: string): boolean => {
175+
const isEdgeApiRouteResource = (resourcePath: string): boolean => {
178176
const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath);
179-
return (
180-
normalizedAbsoluteResourcePath.startsWith(apiRoutesPath + path.sep) &&
181-
dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext))
182-
);
177+
const apiRoutesPath = pagesDirPath ? path.join(pagesDirPath, 'api') : undefined;
178+
if (
179+
!apiRoutesPath ||
180+
!normalizedAbsoluteResourcePath.startsWith(apiRoutesPath + path.sep) ||
181+
!dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext))
182+
) {
183+
return false;
184+
}
185+
186+
// Check if the file exports a config with runtime: 'edge'
187+
// We check the source file to detect edge runtime
188+
try {
189+
if (!fs.existsSync(normalizedAbsoluteResourcePath)) {
190+
return false;
191+
}
192+
const fileContent = fs.readFileSync(normalizedAbsoluteResourcePath, 'utf8');
193+
// Check for edge runtime in config export - handle various formats:
194+
// export const config = { runtime: 'edge' }
195+
// export const config = { runtime: "edge" }
196+
// export const config = { runtime: `edge` }
197+
// Also handle multiline and whitespace variations
198+
// Match: runtime: 'edge', runtime: "edge", runtime: `edge`, or runtime:'edge' (no spaces)
199+
return /runtime\s*:\s*['"`]edge['"`]/.test(fileContent);
200+
} catch {
201+
// If we can't read the file, assume it's not an edge route
202+
return false;
203+
}
183204
};
184205

185206
const possibleMiddlewareLocations = pageExtensions.flatMap(middlewareFileEnding => {
@@ -237,39 +258,15 @@ export function constructWebpackConfigFunction({
237258
],
238259
});
239260

240-
let vercelCronsConfig: VercelCronsConfig = undefined;
241-
try {
242-
if (process.env.VERCEL && userSentryOptions.automaticVercelMonitors) {
243-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
244-
vercelCronsConfig = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'vercel.json'), 'utf8')).crons;
245-
if (vercelCronsConfig) {
246-
debug.log(
247-
"[@sentry/nextjs] Creating Sentry cron monitors for your Vercel Cron Jobs. You can disable this feature by setting the 'automaticVercelMonitors' option to false in you Next.js config.",
248-
);
249-
}
250-
}
251-
} catch (e) {
252-
if ((e as { code: string }).code === 'ENOENT') {
253-
// noop if file does not exist
254-
} else {
255-
// log but noop
256-
debug.error(
257-
'[@sentry/nextjs] Failed to read vercel.json for automatic cron job monitoring instrumentation',
258-
e,
259-
);
260-
}
261-
}
262-
263-
// Wrap api routes
261+
// Wrap edge API routes
264262
newConfig.module.rules.unshift({
265-
test: isApiRouteResource,
263+
test: isEdgeApiRouteResource,
266264
use: [
267265
{
268266
loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'),
269267
options: {
270268
...staticWrappingLoaderOptions,
271-
vercelCronsConfig,
272-
wrappingTargetKind: 'api-route',
269+
wrappingTargetKind: 'edge-api-route',
273270
},
274271
},
275272
],

packages/nextjs/test/config/loaders.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,19 +152,19 @@ describe('webpack loaders', () => {
152152
},
153153
{
154154
resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/api/testApiRoute.ts',
155-
expectedWrappingTargetKind: 'api-route',
155+
expectedWrappingTargetKind: undefined,
156156
},
157157
{
158158
resourcePath: './src/pages/api/testApiRoute.ts',
159-
expectedWrappingTargetKind: 'api-route',
159+
expectedWrappingTargetKind: undefined,
160160
},
161161
{
162162
resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/api/nested/testApiRoute.js',
163-
expectedWrappingTargetKind: 'api-route',
163+
expectedWrappingTargetKind: undefined,
164164
},
165165
{
166166
resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/api/nested/testApiRoute.custom.js',
167-
expectedWrappingTargetKind: 'api-route',
167+
expectedWrappingTargetKind: undefined,
168168
},
169169
{
170170
resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/nested/route.ts',

packages/nextjs/test/config/wrappingLoader.test.ts

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,6 @@ vi.mock('fs', { spy: true });
99
const originalReadfileSync = fs.readFileSync;
1010

1111
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath, options) => {
12-
if (filePath.toString().endsWith('/config/templates/apiWrapperTemplate.js')) {
13-
return originalReadfileSync(
14-
path.join(__dirname, '../../build/cjs/config/templates/apiWrapperTemplate.js'),
15-
options,
16-
);
17-
}
18-
1912
if (filePath.toString().endsWith('/config/templates/pageWrapperTemplate.js')) {
2013
return originalReadfileSync(
2114
path.join(__dirname, '../../build/cjs/config/templates/pageWrapperTemplate.js'),
@@ -65,44 +58,6 @@ const defaultLoaderThis = {
6558
};
6659

6760
describe('wrappingLoader', () => {
68-
it('should correctly wrap API routes on unix', async () => {
69-
const callback = vi.fn();
70-
71-
const userCode = `
72-
export default function handler(req, res) {
73-
res.json({ foo: "bar" });
74-
}
75-
`;
76-
const userCodeSourceMap = undefined;
77-
78-
const loaderPromise = new Promise<void>(resolve => {
79-
const loaderThis = {
80-
...defaultLoaderThis,
81-
resourcePath: '/my/pages/my/route.ts',
82-
callback: callback.mockImplementation(() => {
83-
resolve();
84-
}),
85-
getOptions() {
86-
return {
87-
pagesDir: '/my/pages',
88-
appDir: '/my/app',
89-
pageExtensionRegex: DEFAULT_PAGE_EXTENSION_REGEX,
90-
excludeServerRoutes: [],
91-
wrappingTargetKind: 'api-route',
92-
vercelCronsConfig: undefined,
93-
nextjsRequestAsyncStorageModulePath: '/my/request-async-storage.js',
94-
};
95-
},
96-
} satisfies LoaderThis<WrappingLoaderOptions>;
97-
98-
wrappingLoader.call(loaderThis, userCode, userCodeSourceMap);
99-
});
100-
101-
await loaderPromise;
102-
103-
expect(callback).toHaveBeenCalledWith(null, expect.stringContaining("'/my/route'"), expect.anything());
104-
});
105-
10661
describe('middleware wrapping', () => {
10762
it('should export proxy when user exports named "proxy" export', async () => {
10863
const callback = vi.fn();

0 commit comments

Comments
 (0)