Skip to content

fix(nextjs): Use Next.js internal AsyncStorage #7630

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Mar 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/nextjs/rollup.npm.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default [
'src/config/templates/apiWrapperTemplate.ts',
'src/config/templates/middlewareWrapperTemplate.ts',
'src/config/templates/serverComponentWrapperTemplate.ts',
'src/config/templates/requestAsyncStorageShim.ts',
],

packageSpecificConfig: {
Expand All @@ -43,7 +44,12 @@ export default [
// make it so Rollup calms down about the fact that we're combining default and named exports
exports: 'named',
},
external: ['@sentry/nextjs', 'next/headers', '__SENTRY_WRAPPING_TARGET_FILE__'],
external: [
'@sentry/nextjs',
'next/dist/client/components/request-async-storagee',
'__SENTRY_WRAPPING_TARGET_FILE__',
'__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__',
],
},
}),
),
Expand Down
47 changes: 41 additions & 6 deletions packages/nextjs/src/config/loaders/wrappingLoader.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import commonjs from '@rollup/plugin-commonjs';
import { stringMatchesSomePattern } from '@sentry/utils';
import * as chalk from 'chalk';
import * as fs from 'fs';
import * as path from 'path';
import { rollup } from 'rollup';

import type { LoaderThis } from './types';

// Just a simple placeholder to make referencing module consistent
const SENTRY_WRAPPER_MODULE_NAME = 'sentry-wrapper-module';

// Needs to end in .cjs in order for the `commonjs` plugin to pick it up
const WRAPPING_TARGET_MODULE_NAME = '__SENTRY_WRAPPING_TARGET_FILE__.cjs';

// Non-public API. Can be found here: https://github.com/vercel/next.js/blob/46151dd68b417e7850146d00354f89930d10b43b/packages/next/src/client/components/request-async-storage.ts
const NEXTJS_REQUEST_ASYNC_STORAGE_MODULE_PATH = 'next/dist/client/components/request-async-storagee';

const apiWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'apiWrapperTemplate.js');
const apiWrapperTemplateCode = fs.readFileSync(apiWrapperTemplatePath, { encoding: 'utf8' });

Expand All @@ -15,6 +25,10 @@ const pageWrapperTemplateCode = fs.readFileSync(pageWrapperTemplatePath, { encod
const middlewareWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'middlewareWrapperTemplate.js');
const middlewareWrapperTemplateCode = fs.readFileSync(middlewareWrapperTemplatePath, { encoding: 'utf8' });

const requestAsyncStorageShimPath = path.resolve(__dirname, '..', 'templates', 'requestAsyncStorageShim.js');
const requestAsyncStorageModuleExists = moduleExists(NEXTJS_REQUEST_ASYNC_STORAGE_MODULE_PATH);
let showedMissingAsyncStorageModuleWarning = false;

const serverComponentWrapperTemplatePath = path.resolve(
__dirname,
'..',
Expand All @@ -23,12 +37,6 @@ const serverComponentWrapperTemplatePath = path.resolve(
);
const serverComponentWrapperTemplateCode = fs.readFileSync(serverComponentWrapperTemplatePath, { encoding: 'utf8' });

// Just a simple placeholder to make referencing module consistent
const SENTRY_WRAPPER_MODULE_NAME = 'sentry-wrapper-module';

// Needs to end in .cjs in order for the `commonjs` plugin to pick it up
const WRAPPING_TARGET_MODULE_NAME = '__SENTRY_WRAPPING_TARGET_FILE__.cjs';

type LoaderOptions = {
pagesDir: string;
appDir: string;
Expand All @@ -37,6 +45,15 @@ type LoaderOptions = {
wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component';
};

function moduleExists(id: string): boolean {
try {
require.resolve(id);
return true;
} catch (e) {
return false;
}
}

/**
* Replace the loaded file with a wrapped version the original file. In the wrapped version, the original file is loaded,
* any data-fetching functions (`getInitialProps`, `getStaticProps`, and `getServerSideProps`) or API routes it contains
Expand Down Expand Up @@ -126,6 +143,24 @@ export default function wrappingLoader(

templateCode = serverComponentWrapperTemplateCode;

if (requestAsyncStorageModuleExists) {
templateCode = templateCode.replace(
/__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__/g,
NEXTJS_REQUEST_ASYNC_STORAGE_MODULE_PATH,
);
} else {
if (!showedMissingAsyncStorageModuleWarning) {
// eslint-disable-next-line no-console
console.warn(
`${chalk.yellow('warn')} - The Sentry SDK could not access the ${chalk.bold.cyan(
'RequestAsyncStorage',
)} module. Certain features may not work. There is nothing you can do to fix this yourself, but future SDK updates may resolve this.\n`,
);
showedMissingAsyncStorageModuleWarning = true;
}
templateCode = templateCode.replace(/__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__/g, requestAsyncStorageShimPath);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Instead of warning at runtime, can we add a warning here instead?

}

templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\'));

const componentTypeMatch = path.posix
Expand Down
15 changes: 15 additions & 0 deletions packages/nextjs/src/config/templates/requestAsyncStorageShim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface RequestAsyncStorage {
getStore: () =>
| {
headers: {
get: Headers['get'];
};
}
| undefined;
}

export const requestAsyncStorage: RequestAsyncStorage = {
getStore: () => {
return undefined;
},
};
Original file line number Diff line number Diff line change
@@ -1,28 +1,21 @@
/*
* This file is a template for the code which will be substituted when our webpack loader handles non-API files in the
* `pages/` directory.
*
* We use `__SENTRY_WRAPPING_TARGET_FILE__` as a placeholder for the path to the file being wrapped. Because it's not a real package,
* this causes both TS and ESLint to complain, hence the pragma comments below.
*/

// @ts-ignore See above
// @ts-ignore Because we cannot be sure if the RequestAsyncStorage module exists (it is not part of the Next.js public
// API) we use a shim if it doesn't exist. The logic for this is in the wrapping loader.
// eslint-disable-next-line import/no-unresolved
import * as wrapee from '__SENTRY_WRAPPING_TARGET_FILE__';
import { requestAsyncStorage } from '__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__';
// @ts-ignore We use `__SENTRY_WRAPPING_TARGET_FILE__` as a placeholder for the path to the file being wrapped.
// eslint-disable-next-line import/no-unresolved
import * as serverComponentModule from '__SENTRY_WRAPPING_TARGET_FILE__';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as Sentry from '@sentry/nextjs';
// @ts-ignore This template is only used with the app directory so we know that this dependency exists.
// eslint-disable-next-line import/no-unresolved
import { headers } from 'next/headers';

declare function headers(): { get: (header: string) => string | undefined };
import type { RequestAsyncStorage } from './requestAsyncStorageShim';

type ServerComponentModule = {
declare const requestAsyncStorage: RequestAsyncStorage;

declare const serverComponentModule: {
default: unknown;
};

const serverComponentModule = wrapee as ServerComponentModule;

const serverComponent = serverComponentModule.default;

let wrappedServerComponent;
Expand All @@ -32,21 +25,16 @@ if (typeof serverComponent === 'function') {
// is technically a userfile so it gets the loader magic applied.
wrappedServerComponent = new Proxy(serverComponent, {
apply: (originalFunction, thisArg, args) => {
let sentryTraceHeader: string | undefined = undefined;
let baggageHeader: string | undefined = undefined;

// If we call the headers function inside the build phase, Next.js will automatically mark the server component as
// dynamic(SSR) which we do not want in case the users have a static component.
if (process.env.NEXT_PHASE !== 'phase-production-build') {
// try/catch because calling headers() when a previously statically generated page is being revalidated causes a
// runtime error in next.js as switching a page from static to dynamic during runtime is not allowed
try {
const headersList = headers();
sentryTraceHeader = headersList.get('sentry-trace');
baggageHeader = headersList.get('baggage');
} catch {
/** empty */
}
let sentryTraceHeader: string | undefined | null = undefined;
let baggageHeader: string | undefined | null = undefined;

// We try-catch here just in case the API around `requestAsyncStorage` changes unexpectedly since it is not public API
try {
const requestAsyncStore = requestAsyncStorage.getStore();
sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace');
baggageHeader = requestAsyncStore?.headers.get('baggage');
} catch (e) {
/** empty */
}

return Sentry.wrapServerComponentWithSentry(originalFunction, {
Expand Down