UNPKG

@sentry/wizard

Version:

Sentry wizard helping you to configure your project

429 lines (372 loc) 14.3 kB
import type { ExportNamedDeclaration, Program } from '@babel/types'; import * as fs from 'fs'; import * as path from 'path'; import * as url from 'url'; import chalk from 'chalk'; // @ts-ignore - clack is ESM and TS complains about that. It works though import clack from '@clack/prompts'; // @ts-ignore - magicast is ESM and TS complains about that. It works though import type { ProxifiedModule } from 'magicast'; // @ts-ignore - magicast is ESM and TS complains about that. It works though import { builders, generateCode, loadFile, parseModule } from 'magicast'; // @ts-ignore - magicast is ESM and TS complains about that. It works though import { addVitePlugin } from 'magicast/helpers'; import { getClientHooksTemplate, getServerHooksTemplate } from './templates'; const SVELTE_CONFIG_FILE = 'svelte.config.js'; export type PartialSvelteConfig = { kit?: { files?: { hooks?: { client?: string; server?: string; }; routes?: string; }; }; }; export async function createOrMergeSvelteKitFiles( dsn: string, svelteConfig: PartialSvelteConfig, ): Promise<void> { const { clientHooksPath, serverHooksPath } = getHooksConfigDirs(svelteConfig); // full file paths with correct file ending (or undefined if not found) const originalClientHooksFile = findHooksFile(clientHooksPath); const originalServerHooksFile = findHooksFile(serverHooksPath); const viteConfig = findHooksFile(path.resolve(process.cwd(), 'vite.config')); if (!originalClientHooksFile) { clack.log.info('No client hooks file found, creating a new one.'); await createNewHooksFile(`${clientHooksPath}.js`, 'client', dsn); } if (!originalServerHooksFile) { clack.log.info('No server hooks file found, creating a new one.'); await createNewHooksFile(`${serverHooksPath}.js`, 'server', dsn); } if (originalClientHooksFile) { await mergeHooksFile(originalClientHooksFile, 'client', dsn); } if (originalServerHooksFile) { await mergeHooksFile(originalServerHooksFile, 'server', dsn); } if (viteConfig) { await modifyViteConfig(viteConfig); } } /** * Attempts to read the svelte.config.js file to find the location of the hooks files. * If users specified a custom location, we'll use that. Otherwise, we'll use the default. */ function getHooksConfigDirs(svelteConfig: PartialSvelteConfig): { clientHooksPath: string; serverHooksPath: string; } { const relativeUserClientHooksPath = svelteConfig?.kit?.files?.hooks?.client; const relativeUserServerHooksPath = svelteConfig?.kit?.files?.hooks?.server; const userClientHooksPath = relativeUserClientHooksPath && path.resolve(process.cwd(), relativeUserClientHooksPath); const userServerHooksPath = relativeUserServerHooksPath && path.resolve(process.cwd(), relativeUserServerHooksPath); const defaulHooksDir = path.resolve(process.cwd(), 'src'); const defaultClientHooksPath = path.resolve(defaulHooksDir, 'hooks.client'); // file ending missing on purpose const defaultServerHooksPath = path.resolve(defaulHooksDir, 'hooks.server'); // same here return { clientHooksPath: userClientHooksPath || defaultClientHooksPath, serverHooksPath: userServerHooksPath || defaultServerHooksPath, }; } /** * Checks if a hooks file exists and returns the full path to the file with the correct file type. */ function findHooksFile(hooksFile: string): string | undefined { const possibleFileTypes = ['.js', '.ts', '.mjs']; return possibleFileTypes .map((type) => `${hooksFile}${type}`) .find((file) => fs.existsSync(file)); } /** * Reads the template, replaces the dsn placeholder with the actual dsn and writes the file to @param hooksFileDest */ async function createNewHooksFile( hooksFileDest: string, hooktype: 'client' | 'server', dsn: string, ): Promise<void> { const filledTemplate = hooktype === 'client' ? getClientHooksTemplate(dsn) : getServerHooksTemplate(dsn); await fs.promises.mkdir(path.dirname(hooksFileDest), { recursive: true }); await fs.promises.writeFile(hooksFileDest, filledTemplate); clack.log.success(`Created ${hooksFileDest}`); } /** * Merges the users' hooks file with Sentry-related code. * * Both hooks: * - add import * as Sentry * - add Sentry.init * - add handleError hook wrapper * * Additionally in Server hook: * - add handle hook handler */ async function mergeHooksFile( hooksFile: string, hookType: 'client' | 'server', dsn: string, ): Promise<void> { const originalHooksMod = await loadFile(hooksFile); if (hasSentryContent(path.basename(hooksFile), originalHooksMod.$code)) { // We don't want to mess with files that already have Sentry content. // Let's just bail out at this point. return; } originalHooksMod.imports.$add({ from: '@sentry/sveltekit', imported: '*', local: 'Sentry', }); if (hookType === 'client') { insertClientInitCall(dsn, originalHooksMod); } else { insertServerInitCall(dsn, originalHooksMod); } wrapHandleError(originalHooksMod); if (hookType === 'server') { wrapHandle(originalHooksMod); } const modifiedCode = originalHooksMod.generate().code; await fs.promises.writeFile(hooksFile, modifiedCode); clack.log.success(`Added Sentry code to ${hooksFile}`); } function insertClientInitCall( dsn: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any originalHooksMod: ProxifiedModule<any>, ): void { const initCallComment = ` // If you don't want to use Session Replay, remove the \`Replay\` integration, // \`replaysSessionSampleRate\` and \`replaysOnErrorSampleRate\` options.`; // This assignment of any values is fine because we're just creating a function call in magicast // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const initCall = builders.functionCall('Sentry.init', { dsn, tracesSampleRate: 1.0, replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0, integrations: [builders.newExpression('Sentry.Replay')], }); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const initCallWithComment = builders.raw( // eslint-disable-next-line @typescript-eslint/no-unsafe-argument `${initCallComment}\n${generateCode(initCall).code}`, ); const originalHooksModAST = originalHooksMod.$ast as Program; const initCallInsertionIndex = getInitCallInsertionIndex(originalHooksModAST); originalHooksModAST.body.splice( initCallInsertionIndex, 0, // @ts-ignore - string works here because the AST is proxified by magicast // eslint-disable-next-line @typescript-eslint/no-unsafe-argument generateCode(initCallWithComment).code, ); } function insertServerInitCall( dsn: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any originalHooksMod: ProxifiedModule<any>, ): void { // This assignment of any values is fine because we're just creating a function call in magicast // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const initCall = builders.functionCall('Sentry.init', { dsn, tracesSampleRate: 1.0, }); const originalHooksModAST = originalHooksMod.$ast as Program; const initCallInsertionIndex = getInitCallInsertionIndex(originalHooksModAST); originalHooksModAST.body.splice( initCallInsertionIndex, 0, // @ts-ignore - string works here because the AST is proxified by magicast // eslint-disable-next-line @typescript-eslint/no-unsafe-argument generateCode(initCall).code, ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any function wrapHandleError(mod: ProxifiedModule<any>): void { const modAst = mod.exports.$ast as Program; const namedExports = modAst.body.filter( (node) => node.type === 'ExportNamedDeclaration', ) as ExportNamedDeclaration[]; let foundHandleError = false; namedExports.forEach((modExport) => { const declaration = modExport.declaration; if (!declaration) { return; } if (declaration.type === 'FunctionDeclaration') { if (!declaration.id || declaration.id.name !== 'handleError') { return; } foundHandleError = true; const userCode = generateCode(declaration).code; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment mod.exports.handleError = builders.raw( `Sentry.handleErrorWithSentry(${userCode.replace( 'handleError', '_handleError', )})`, ); // because magicast doesn't overwrite the original function export, we need to remove it manually modAst.body = modAst.body.filter((node) => node !== modExport); } else if (declaration.type === 'VariableDeclaration') { const declarations = declaration.declarations; declarations.forEach((declaration) => { // @ts-ignore - id should always have a name in this case if (!declaration.id || declaration.id.name !== 'handleError') { return; } foundHandleError = true; const userCode = declaration.init; const stringifiedUserCode = userCode ? generateCode(userCode).code : ''; // @ts-ignore - we can just place a string here, magicast will convert it to a node declaration.init = `Sentry.handleErrorWithSentry(${stringifiedUserCode})`; }); } }); if (!foundHandleError) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment mod.exports.handleError = builders.functionCall( 'Sentry.handleErrorWithSentry', ); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any function wrapHandle(mod: ProxifiedModule<any>): void { const modAst = mod.exports.$ast as Program; const namedExports = modAst.body.filter( (node) => node.type === 'ExportNamedDeclaration', ) as ExportNamedDeclaration[]; let foundHandle = false; namedExports.forEach((modExport) => { const declaration = modExport.declaration; if (!declaration) { return; } if (declaration.type === 'FunctionDeclaration') { if (!declaration.id || declaration.id.name !== 'handle') { return; } foundHandle = true; const userCode = generateCode(declaration).code; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment mod.exports.handle = builders.raw( `sequence(Sentry.sentryHandle(), ${userCode.replace( 'handle', '_handle', )})`, ); // because of an issue with magicast, we need to remove the original export modAst.body = modAst.body.filter((node) => node !== modExport); } else if (declaration.type === 'VariableDeclaration') { const declarations = declaration.declarations; declarations.forEach((declaration) => { // @ts-ignore - id should always have a name in this case if (!declaration.id || declaration.id.name !== 'handle') { return; } const userCode = declaration.init; const stringifiedUserCode = userCode ? generateCode(userCode).code : ''; // @ts-ignore - we can just place a string here, magicast will convert it to a node declaration.init = `sequence(Sentry.sentryHandle(), ${stringifiedUserCode})`; foundHandle = true; }); } }); if (!foundHandle) { // can't use builders.functionCall here because it doesn't yet // support member expressions (Sentry.sentryHandle()) in args // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment mod.exports.handle = builders.raw('sequence(Sentry.sentryHandle())'); } try { mod.imports.$add({ from: '@sveltejs/kit/hooks', imported: 'sequence', local: 'sequence', }); } catch (_) { // It's possible sequence is already imported. in this case, magicast throws but that's fine. } } /** Checks if the Sentry SvelteKit SDK is already mentioned in the file */ function hasSentryContent(fileName: string, fileContent: string): boolean { if (fileContent.includes('@sentry/sveltekit')) { clack.log.warn( `File ${chalk.cyan(path.basename(fileName))} already contains Sentry code. Skipping adding Sentry functionality to ${chalk.cyan( path.basename(fileName), )}.`, ); return true; } return false; } export async function loadSvelteConfig(): Promise<PartialSvelteConfig> { const configFilePath = path.join(process.cwd(), SVELTE_CONFIG_FILE); try { if (!fs.existsSync(configFilePath)) { return {}; } const configUrl = url.pathToFileURL(configFilePath).href; const svelteConfigModule = (await import(configUrl)) as { default: PartialSvelteConfig; }; return svelteConfigModule?.default || {}; } catch (e: unknown) { clack.log.error(`Couldn't load ${SVELTE_CONFIG_FILE}. Please make sure, you're running this wizard with Node 16 or newer`); clack.log.info( chalk.dim( typeof e === 'object' && e != null && 'toString' in e ? e.toString() : typeof e === 'string' ? e : 'Unknown error', ), ); return {}; } } async function modifyViteConfig(viteConfigPath: string): Promise<void> { const viteConfigContent = ( await fs.promises.readFile(viteConfigPath, 'utf-8') ).toString(); if (hasSentryContent(viteConfigPath, viteConfigContent)) { return; } const viteModule = parseModule(viteConfigContent); addVitePlugin(viteModule, { imported: 'sentrySvelteKit', from: '@sentry/sveltekit', constructor: 'sentrySvelteKit', index: 0, }); const code = generateCode(viteModule.$ast).code; await fs.promises.writeFile(viteConfigPath, code); } /** * We want to insert the init call on top of the file but after all import statements */ function getInitCallInsertionIndex(originalHooksModAST: Program): number { // We need to deep-copy here because reverse mutates in place const copiedBodyNodes = [...originalHooksModAST.body]; const lastImportDeclaration = copiedBodyNodes .reverse() .find((node) => node.type === 'ImportDeclaration'); const initCallInsertionIndex = lastImportDeclaration ? originalHooksModAST.body.indexOf(lastImportDeclaration) + 1 : 0; return initCallInsertionIndex; }