Skip to content

Commit 4c93809

Browse files
committed
feat(sveltekit): Add source maps support for Vercel (lambda)
1 parent 8482c0a commit 4c93809

File tree

9 files changed

+154
-25
lines changed

9 files changed

+154
-25
lines changed

packages/sveltekit/.eslintrc.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ module.exports = {
1717
project: ['tsconfig.test.json'],
1818
},
1919
},
20+
{
21+
files: ['./src/vite/**', './src/server/**'],
22+
'@sentry-internal/sdk/no-optional-chaining': 'off',
23+
},
2024
],
2125
extends: ['../../.eslintrc.js'],
2226
};

packages/sveltekit/src/server/utils.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
import type { DynamicSamplingContext, StackFrame, TraceparentData } from '@sentry/types';
2-
import { baggageHeaderToDynamicSamplingContext, basename, extractTraceparentData } from '@sentry/utils';
2+
import {
3+
baggageHeaderToDynamicSamplingContext,
4+
basename,
5+
escapeStringForRegex,
6+
extractTraceparentData,
7+
} from '@sentry/utils';
38
import type { RequestEvent } from '@sveltejs/kit';
49

510
import { WRAPPED_MODULE_SUFFIX } from '../vite/autoInstrument';
611

12+
/**
13+
* Extend the `global` type with custom properties that are
14+
* injected by the SvelteKit SDK at build time.
15+
* @see packages/sveltekit/src/vite/sourcemaps.ts
16+
*/
17+
export type GlobalWithSentryValues = typeof globalThis & {
18+
__sentry_sveltekit_output_dir?: string;
19+
};
20+
721
/**
822
* Takes a request event and extracts traceparent and DSC data
923
* from the `sentry-trace` and `baggage` DSC headers.
@@ -35,7 +49,8 @@ export function rewriteFramesIteratee(frame: StackFrame): StackFrame {
3549
if (!frame.filename) {
3650
return frame;
3751
}
38-
52+
const globalWithSentryValues: GlobalWithSentryValues = globalThis;
53+
const svelteKitBuildOutDir = globalWithSentryValues.__sentry_sveltekit_output_dir;
3954
const prefix = 'app:///';
4055

4156
// Check if the frame filename begins with `/` or a Windows-style prefix such as `C:\`
@@ -48,8 +63,13 @@ export function rewriteFramesIteratee(frame: StackFrame): StackFrame {
4863
.replace(/\\/g, '/') // replace all `\\` instances with `/`
4964
: frame.filename;
5065

51-
const base = basename(filename);
52-
frame.filename = `${prefix}${base}`;
66+
let strippedFilename;
67+
if (svelteKitBuildOutDir) {
68+
strippedFilename = filename.replace(new RegExp(`^.*${escapeStringForRegex(svelteKitBuildOutDir)}\/server\/`), '');
69+
} else {
70+
strippedFilename = basename(filename);
71+
}
72+
frame.filename = `${prefix}${strippedFilename}`;
5373
}
5474

5575
delete frame.module;

packages/sveltekit/src/vite/sentryVitePlugins.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {}
103103
const pluginOptions = {
104104
...mergedOptions.sourceMapsUploadOptions,
105105
debug: mergedOptions.debug, // override the plugin's debug flag with the one from the top-level options
106+
adapter: mergedOptions.adapter,
106107
};
107108
sentryPlugins.push(await makeCustomSentryVitePlugin(pluginOptions));
108109
}

packages/sveltekit/src/vite/sourceMaps.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import * as sorcery from 'sorcery';
1111
import type { Plugin } from 'vite';
1212

1313
import { WRAPPED_MODULE_SUFFIX } from './autoInstrument';
14-
import { getAdapterOutputDir, loadSvelteConfig } from './svelteConfig';
14+
import type { SupportedSvelteKitAdapters } from './detectAdapter';
15+
import { getAdapterOutputDir, getHooksFileName, loadSvelteConfig } from './svelteConfig';
1516

1617
// sorcery has no types, so these are some basic type definitions:
1718
type Chain = {
@@ -26,6 +27,10 @@ type SentryVitePluginOptionsOptionalInclude = Omit<SentryVitePluginOptions, 'inc
2627
include?: SentryVitePluginOptions['include'];
2728
};
2829

30+
type CustomSentryVitePluginOptions = SentryVitePluginOptionsOptionalInclude & {
31+
adapter: SupportedSvelteKitAdapters;
32+
};
33+
2934
// storing this in the module scope because `makeCustomSentryVitePlugin` is called multiple times
3035
// and we only want to generate a uuid once in case we have to fall back to it.
3136
const release = detectSentryRelease();
@@ -46,18 +51,15 @@ const release = detectSentryRelease();
4651
*
4752
* @returns the custom Sentry Vite plugin
4853
*/
49-
export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptionsOptionalInclude): Promise<Plugin> {
54+
export async function makeCustomSentryVitePlugin(options?: CustomSentryVitePluginOptions): Promise<Plugin> {
5055
const svelteConfig = await loadSvelteConfig();
5156

52-
const outputDir = await getAdapterOutputDir(svelteConfig);
57+
const usedAdapter = options?.adapter || 'other';
58+
const outputDir = await getAdapterOutputDir(svelteConfig, usedAdapter);
5359
const hasSentryProperties = fs.existsSync(path.resolve(process.cwd(), 'sentry.properties'));
5460

5561
const defaultPluginOptions: SentryVitePluginOptions = {
56-
include: [
57-
{ paths: [`${outputDir}/client`] },
58-
{ paths: [`${outputDir}/server/chunks`] },
59-
{ paths: [`${outputDir}/server`], ignore: ['chunks/**'] },
60-
],
62+
include: [{ paths: [`${outputDir}/client`] }, { paths: [`${outputDir}/server`] }],
6163
configFile: hasSentryProperties ? 'sentry.properties' : undefined,
6264
release,
6365
};
@@ -74,6 +76,8 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
7476

7577
let isSSRBuild = true;
7678

79+
const serverHooksFile = getHooksFileName(svelteConfig, 'server');
80+
7781
const customPlugin: Plugin = {
7882
name: 'sentry-upload-source-maps',
7983
apply: 'build', // only apply this plugin at build time
@@ -84,7 +88,6 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
8488
buildStart,
8589
resolveId,
8690
renderChunk,
87-
transform,
8891

8992
// Modify the config to generate source maps
9093
config: config => {
@@ -109,6 +112,18 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
109112
}
110113
},
111114

115+
transform: async (code, id) => {
116+
let modifiedCode = code;
117+
const isServerHooksFile = new RegExp(`\/${escapeStringForRegex(serverHooksFile)}(\.(js|ts|mjs|mts))?`).test(id);
118+
119+
if (isServerHooksFile) {
120+
let injectedCode = `global.__sentry_sveltekit_output_dir = "${outputDir || 'undefined'}";\n`;
121+
modifiedCode = `${code}\n${injectedCode}`;
122+
}
123+
// @ts-ignore - this hook exists on the plugin!
124+
return sentryPlugin.transform(modifiedCode, id);
125+
},
126+
112127
// We need to start uploading source maps later than in the original plugin
113128
// because SvelteKit is invoking the adapter at closeBundle.
114129
// This means that we need to wait until the adapter is done before we start uploading.

packages/sveltekit/src/vite/svelteConfig.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Builder, Config } from '@sveltejs/kit';
44
import * as fs from 'fs';
55
import * as path from 'path';
66
import * as url from 'url';
7+
import { SupportedSvelteKitAdapters } from './detectAdapter';
78

89
/**
910
* Imports the svelte.config.js file and returns the config object.
@@ -35,28 +36,46 @@ export async function loadSvelteConfig(): Promise<Config> {
3536
}
3637
}
3738

39+
/**
40+
* Reads a custom hooks directory from the SvelteKit config. In case no custom hooks
41+
* directory is specified, the default directory is returned.
42+
*/
43+
export function getHooksFileName(svelteConfig: Config, hookType: 'client' | 'server') {
44+
return svelteConfig.kit?.files?.hooks?.[hookType] || `src/hooks.${hookType}`;
45+
}
46+
3847
/**
3948
* Attempts to read a custom output directory that can be specidied in the options
4049
* of a SvelteKit adapter. If no custom output directory is specified, the default
4150
* directory is returned.
42-
*
43-
* To get the directory, we have to apply a hack and call the adapter's adapt method
51+
*/
52+
export async function getAdapterOutputDir(svelteConfig: Config, adapter: SupportedSvelteKitAdapters): Promise<string> {
53+
if (adapter === 'node') {
54+
return await getNodeAdapterOutputDir(svelteConfig);
55+
}
56+
57+
// Auto and Vercel adapters simply use config.kit.outDir
58+
// Let's also use this directory for the 'other' case
59+
return path.join(svelteConfig.kit?.outDir || '.svelte-kit', 'output');
60+
}
61+
62+
/**
63+
* To get the Node adapter output directory, we have to apply a hack and call the adapter's adapt method
4464
* with a custom adapter `Builder` that only calls the `writeClient` method.
4565
* This method is the first method that is called with the output directory.
4666
* Once we obtained the output directory, we throw an error to exit the adapter.
4767
*
4868
* see: https://github.com/sveltejs/kit/blob/master/packages/adapter-node/index.js#L17
49-
*
5069
*/
51-
export async function getAdapterOutputDir(svelteConfig: Config): Promise<string> {
70+
async function getNodeAdapterOutputDir(svelteConfig: Config): Promise<string> {
5271
// 'build' is the default output dir for the node adapter
5372
let outputDir = 'build';
5473

5574
if (!svelteConfig.kit?.adapter) {
5675
return outputDir;
5776
}
5877

59-
const adapter = svelteConfig.kit.adapter;
78+
const nodeAdapter = svelteConfig.kit.adapter;
6079

6180
const adapterBuilder: Builder = {
6281
writeClient(dest: string) {
@@ -85,7 +104,7 @@ export async function getAdapterOutputDir(svelteConfig: Config): Promise<string>
85104
};
86105

87106
try {
88-
await adapter.adapt(adapterBuilder);
107+
await nodeAdapter.adapt(adapterBuilder);
89108
} catch (_) {
90109
// We expect the adapter to throw in writeClient!
91110
}

packages/sveltekit/test/server/utils.test.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { RewriteFrames } from '@sentry/integrations';
22
import type { StackFrame } from '@sentry/types';
3+
import { basename } from '@sentry/utils';
34

4-
import { getTracePropagationData, rewriteFramesIteratee } from '../../src/server/utils';
5+
import { getTracePropagationData, GlobalWithSentryValues, rewriteFramesIteratee } from '../../src/server/utils';
56

67
const MOCK_REQUEST_EVENT: any = {
78
request: {
@@ -69,7 +70,7 @@ describe('rewriteFramesIteratee', () => {
6970
expect(result).not.toHaveProperty('module');
7071
});
7172

72-
it('does the same filename modification as the default RewriteFrames iteratee', () => {
73+
it('does the same filename modification as the default RewriteFrames iteratee if no output dir is available', () => {
7374
const frame: StackFrame = {
7475
filename: '/some/path/to/server/chunks/3-ab34d22f.js',
7576
lineno: 1,
@@ -94,4 +95,36 @@ describe('rewriteFramesIteratee', () => {
9495

9596
expect(result).toStrictEqual(defaultResult);
9697
});
98+
99+
it.each([
100+
['adapter-node', 'build', '/absolute/path/to/build/server/chunks/3-ab34d22f.js', 'app:///chunks/3-ab34d22f.js'],
101+
[
102+
'adapter-auto',
103+
'.svelte-kit/output',
104+
'/absolute/path/to/.svelte-kit/output/server/entries/pages/page.ts.js',
105+
'app:///entries/pages/page.ts.js',
106+
],
107+
])(
108+
'removes the absolut path to the server output dir, if the output dir is available (%s)',
109+
(_, outputDir, frameFilename, modifiedFilename) => {
110+
(global as GlobalWithSentryValues).__sentry_sveltekit_output_dir = outputDir;
111+
112+
const frame: StackFrame = {
113+
filename: frameFilename,
114+
lineno: 1,
115+
colno: 1,
116+
module: basename(frameFilename),
117+
};
118+
119+
const result = rewriteFramesIteratee({ ...frame });
120+
121+
expect(result).toStrictEqual({
122+
filename: modifiedFilename,
123+
lineno: 1,
124+
colno: 1,
125+
});
126+
127+
delete (global as GlobalWithSentryValues).__sentry_sveltekit_output_dir;
128+
},
129+
);
97130
});

packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ describe('sentrySvelteKit()', () => {
7272
ignore: ['bar.js'],
7373
},
7474
autoInstrument: false,
75+
adapter: 'vercel',
7576
});
7677
const plugin = plugins[0];
7778

@@ -80,6 +81,7 @@ describe('sentrySvelteKit()', () => {
8081
debug: true,
8182
ignore: ['bar.js'],
8283
include: ['foo.js'],
84+
adapter: 'vercel',
8385
});
8486
});
8587

packages/sveltekit/test/vite/sourceMaps.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const mockedSentryVitePlugin = {
66
buildStart: vi.fn(),
77
resolveId: vi.fn(),
88
renderChunk: vi.fn(),
9-
transform: vi.fn(),
9+
transform: vi.fn().mockImplementation((code: string, _id: string) => code),
1010
writeBundle: vi.fn(),
1111
};
1212

@@ -54,6 +54,15 @@ describe('makeCustomSentryVitePlugin()', () => {
5454
});
5555
});
5656

57+
it('injects the output dir into the server hooks file', async () => {
58+
const plugin = await makeCustomSentryVitePlugin();
59+
// @ts-ignore this function exists!
60+
const transformedCode = await plugin.transform('foo', '/src/hooks.server.ts');
61+
const expectedtransformedCode = 'foo\nglobal.__sentry_sveltekit_output_dir = ".svelte-kit/output";\n';
62+
expect(mockedSentryVitePlugin.transform).toHaveBeenCalledWith(expectedtransformedCode, '/src/hooks.server.ts');
63+
expect(transformedCode).toEqual(expectedtransformedCode);
64+
});
65+
5766
it('uploads source maps during the SSR build', async () => {
5867
const plugin = await makeCustomSentryVitePlugin();
5968
// @ts-ignore this function exists!

packages/sveltekit/test/vite/svelteConfig.test.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { vi } from 'vitest';
2+
import { SupportedSvelteKitAdapters } from '../../src/vite/detectAdapter';
23

3-
import { getAdapterOutputDir, loadSvelteConfig } from '../../src/vite/svelteConfig';
4+
import { getAdapterOutputDir, getHooksFileName, loadSvelteConfig } from '../../src/vite/svelteConfig';
45

56
let existsFile;
67

@@ -62,8 +63,33 @@ describe('getAdapterOutputDir', () => {
6263
},
6364
};
6465

65-
it('returns the output directory of the adapter', async () => {
66-
const outputDir = await getAdapterOutputDir({ kit: { adapter: mockedAdapter } });
66+
it('returns the output directory of the Node adapter', async () => {
67+
const outputDir = await getAdapterOutputDir({ kit: { adapter: mockedAdapter } }, 'node');
6768
expect(outputDir).toEqual('customBuildDir');
6869
});
70+
71+
it.each(['vercel', 'auto', 'other'] as SupportedSvelteKitAdapters[])(
72+
'returns the config.kit.outdir directory for adapter-%s',
73+
async adapter => {
74+
const outputDir = await getAdapterOutputDir({ kit: { outDir: 'customOutDir' } }, adapter);
75+
expect(outputDir).toEqual('customOutDir/output');
76+
},
77+
);
78+
79+
it('falls back to the default out dir for all other adapters if outdir is not specified in the config', async () => {
80+
const outputDir = await getAdapterOutputDir({ kit: {} }, 'vercel');
81+
expect(outputDir).toEqual('.svelte-kit/output');
82+
});
83+
});
84+
85+
describe('getHooksFileName', () => {
86+
it('returns the default hooks file name if no custom hooks file is specified', () => {
87+
const hooksFileName = getHooksFileName({}, 'server');
88+
expect(hooksFileName).toEqual('src/hooks.server');
89+
});
90+
91+
it('returns the custom hooks file name if specified in the config', () => {
92+
const hooksFileName = getHooksFileName({ kit: { files: { hooks: { server: 'serverhooks' } } } }, 'server');
93+
expect(hooksFileName).toEqual('serverhooks');
94+
});
6995
});

0 commit comments

Comments
 (0)