Skip to content

Commit

Permalink
feat(react-native): Add support for Expo projects (#505)
Browse files Browse the repository at this point in the history
  • Loading branch information
krystofwoldrich authored Jul 8, 2024
1 parent 583060f commit fe849fe
Show file tree
Hide file tree
Showing 13 changed files with 896 additions and 89 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- feat(react-native): Add support for Expo projects (#505)

## 3.24.1

- fix(nextjs): Add trailing comma to `sentryUrl` option in `withSentryConfig` template (#601)
Expand Down
55 changes: 55 additions & 0 deletions src/react-native/expo-env-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// @ts-ignore - clack is ESM and TS complains about that. It works though
import * as clack from '@clack/prompts';
import chalk from 'chalk';
import fs from 'fs';
import * as Sentry from '@sentry/node';
import { RNCliSetupConfigContent } from './react-native-wizard';
import { addToGitignore } from './git';

const EXPO_ENV_LOCAL_FILE = '.env.local';

export async function addExpoEnvLocal(
options: RNCliSetupConfigContent,
): Promise<boolean> {
const newContent = `#DO NOT COMMIT THIS\nSENTRY_AUTH_TOKEN=${options.authToken}\n`;

const added = await addToGitignore(EXPO_ENV_LOCAL_FILE);
if (added) {
clack.log.success(
`Added ${chalk.cyan(EXPO_ENV_LOCAL_FILE)} to .gitignore.`,
);
} else {
clack.log.error(
`Could not add ${chalk.cyan(
EXPO_ENV_LOCAL_FILE,
)} to .gitignore, please add it to not commit your auth key.`,
);
}

if (!fs.existsSync(EXPO_ENV_LOCAL_FILE)) {
try {
await fs.promises.writeFile(EXPO_ENV_LOCAL_FILE, newContent);
Sentry.setTag('expo-env-local', 'written');
clack.log.success(`Written ${chalk.cyan(EXPO_ENV_LOCAL_FILE)}.`);
return true;
} catch (error) {
Sentry.setTag('expo-env-local', 'write-error');
clack.log.error(`Unable to write ${chalk.cyan(EXPO_ENV_LOCAL_FILE)}.`);
return false;
}
}

Sentry.setTag('expo-env-local', 'exists');
clack.log.info(`Updating existing ${chalk.cyan(EXPO_ENV_LOCAL_FILE)}.`);

try {
await fs.promises.appendFile(EXPO_ENV_LOCAL_FILE, newContent);
Sentry.setTag('expo-env-local', 'updated');
clack.log.success(`Updated ${chalk.cyan(EXPO_ENV_LOCAL_FILE)}.`);
return true;
} catch (error) {
Sentry.setTag('expo-env-local', 'update-error');
clack.log.error(`Unable to update ${chalk.cyan(EXPO_ENV_LOCAL_FILE)}.`);
return false;
}
}
212 changes: 212 additions & 0 deletions src/react-native/expo-metro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import * as fs from 'fs';
// @ts-ignore - clack is ESM and TS complains about that. It works though
import * as clack from '@clack/prompts';
// @ts-ignore - magicast is ESM and TS complains about that. It works though
import { ProxifiedModule } from 'magicast';
import chalk from 'chalk';
import * as Sentry from '@sentry/node';

import { getLastRequireIndex, hasSentryContent } from '../utils/ast-utils';
import {
makeCodeSnippet,
showCopyPasteInstructions,
} from '../utils/clack-utils';

import { metroConfigPath, parseMetroConfig, writeMetroConfig } from './metro';

import * as recast from 'recast';
import x = recast.types;
import t = x.namedTypes;

const b = recast.types.builders;

export async function addSentryToExpoMetroConfig() {
if (!fs.existsSync(metroConfigPath)) {
const success = await createSentryExpoMetroConfig();
if (!success) {
Sentry.setTag('expo-metro-config', 'create-new-error');
return await showInstructions();
}
Sentry.setTag('expo-metro-config', 'created-new');
return undefined;
}

const mod = await parseMetroConfig();

let didPatch = false;
try {
didPatch = patchMetroInMemory(mod);
} catch (e) {
// noop
}
if (!didPatch) {
Sentry.setTag('expo-metro-config', 'patch-error');
clack.log.error(
`Could not patch ${chalk.cyan(
metroConfigPath,
)} with Sentry configuration.`,
);
return await showInstructions();
}

const saved = await writeMetroConfig(mod);
if (saved) {
Sentry.setTag('expo-metro-config', 'patch-saved');
clack.log.success(
chalk.green(`${chalk.cyan(metroConfigPath)} changes saved.`),
);
} else {
Sentry.setTag('expo-metro-config', 'patch-save-error');
clack.log.warn(
`Could not save changes to ${chalk.cyan(
metroConfigPath,
)}, please follow the manual steps.`,
);
return await showInstructions();
}
}

export function patchMetroInMemory(mod: ProxifiedModule): boolean {
const ast = mod.$ast as t.Program;

if (hasSentryContent(ast)) {
clack.log.warn(
`The ${chalk.cyan(
metroConfigPath,
)} file already has Sentry configuration.`,
);
return false;
}

let didReplaceDefaultConfigCall = false;

recast.visit(ast, {
visitVariableDeclaration(path) {
const { node } = path;

if (
// path is require("expo/metro-config")
// and only getDefaultConfig is being destructured
// then remove the entire declaration
node.declarations.length > 0 &&
node.declarations[0].type === 'VariableDeclarator' &&
node.declarations[0].init &&
node.declarations[0].init.type === 'CallExpression' &&
node.declarations[0].init.callee &&
node.declarations[0].init.callee.type === 'Identifier' &&
node.declarations[0].init.callee.name === 'require' &&
node.declarations[0].init.arguments[0].type === 'StringLiteral' &&
node.declarations[0].init.arguments[0].value === 'expo/metro-config' &&
node.declarations[0].id.type === 'ObjectPattern' &&
node.declarations[0].id.properties.length === 1 &&
node.declarations[0].id.properties[0].type === 'ObjectProperty' &&
node.declarations[0].id.properties[0].key.type === 'Identifier' &&
node.declarations[0].id.properties[0].key.name === 'getDefaultConfig'
) {
path.prune();
return false;
}

this.traverse(path);
},

visitCallExpression(path) {
const { node } = path;
if (
// path is getDefaultConfig
// then rename it to getSentryExpoConfig
node.callee.type === 'Identifier' &&
node.callee.name === 'getDefaultConfig'
) {
node.callee.name = 'getSentryExpoConfig';
didReplaceDefaultConfigCall = true;
return false;
}

this.traverse(path);
},
});

if (!didReplaceDefaultConfigCall) {
clack.log.warn(
`Could not find \`getDefaultConfig\` in ${chalk.cyan(metroConfigPath)}.`,
);
return false;
}

addSentryExpoConfigRequire(ast);

return true;
}

export function addSentryExpoConfigRequire(program: t.Program) {
const lastRequireIndex = getLastRequireIndex(program);
const sentryExpoConfigRequire = createSentryExpoConfigRequire();
program.body.splice(lastRequireIndex + 1, 0, sentryExpoConfigRequire);
}

/**
* Creates const { getSentryExpoConfig } = require("@sentry/react-native/metro");
*/
function createSentryExpoConfigRequire() {
return b.variableDeclaration('const', [
b.variableDeclarator(
b.objectPattern([
b.objectProperty.from({
key: b.identifier('getSentryExpoConfig'),
value: b.identifier('getSentryExpoConfig'),
shorthand: true,
}),
]),
b.callExpression(b.identifier('require'), [
b.literal('@sentry/react-native/metro'),
]),
),
]);
}

async function createSentryExpoMetroConfig(): Promise<boolean> {
const snippet = `const { getSentryExpoConfig } = require("@sentry/react-native/metro");
const config = getSentryExpoConfig(__dirname);
module.exports = config;
`;
try {
await fs.promises.writeFile(metroConfigPath, snippet);
} catch (e) {
clack.log.error(
`Could not create ${chalk.cyan(
metroConfigPath,
)} with Sentry configuration.`,
);
return false;
}
clack.log.success(
`Created ${chalk.cyan(metroConfigPath)} with Sentry configuration.`,
);
return true;
}

function showInstructions() {
return showCopyPasteInstructions(
metroConfigPath,
getMetroWithSentryExpoConfigSnippet(true),
);
}

function getMetroWithSentryExpoConfigSnippet(colors: boolean): string {
return makeCodeSnippet(colors, (unchanged, plus, minus) =>
unchanged(`${minus(
`// const { getDefaultConfig } = require("expo/metro-config");`,
)}
${plus(
`const { getSentryExpoConfig } = require("@sentry/react-native/metro");`,
)}
${minus(`// const config = getDefaultConfig(__dirname);`)}
${plus(`const config = getSentryExpoConfig(__dirname);`)}
module.exports = config;`),
);
}
Loading

0 comments on commit fe849fe

Please sign in to comment.