mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
TanStack server maps now preserve original TypeScript locations through both Nitro build stages. Sentry can upload those maps before final server and public maps are removed; intermediate SSR maps remain available for composition. **Changed:** - Enable Nitro server maps with original source content and compose adjacent SSR maps through a build-only Vite loader scoped to the Nitro environment. - Keep intermediate maps until Nitro consumes them. Apply Sentry’s bundler plugin to client and final server outputs, preventing intermediate SSR IDs from overriding the final uploaded map ID. Remove final server maps after eligible uploads; skipped or unauthenticated uploads retain private maps. - Preserve the existing Sentry release, project, credentials, middleware, and separate Next configuration. **Added:** - Real two-stage Node and Vercel build regressions for original TypeScript positions, runtime debug-ID matching against the installed Sentry SDK, map cleanup timing, malformed maps, and loader boundaries. ## To test - Build an actual TanStack preview with the existing Sentry upload settings. Confirm client and final server uploads include maps, intermediate SSR maps remain available for composition, final/public maps are removed, and the deployed function remains within its size limit. - Inspect a public JavaScript asset's map URL; it should not expose a source map. - Build with uploads skipped or credentials absent; private server maps should remain, with no public maps. - Reload an existing project and navigate through general settings and an existing Edge Function without editing or submitting anything. - Before production rollout, confirm browser and server events resolve to useful original source locations in the intended Sentry project. Validation: 22 focused tests, Studio typecheck, scoped ESLint, formatting, knip, full source lint ratchet, and both framework production builds passed. Full-app artifact tracing resolves a compiled handler to its original TypeScript line with exact source content. The skipped-upload Vercel build contains zero public maps and no function symlinks. Local TanStack browser checks passed for project/reload, untouched general settings and support, and an existing function editor/reload; a final retest after the runtime-ID fix passed with no module or Sentry errors. A previously observed devtools support-lifecycle warning remains unattributed. An explicit TanStack preview of this commit reached READY: client and final Nitro uploads succeeded, with no intermediate SSR upload. A shell-referenced JavaScript asset returned 200 with immutable caching and its map URL returned 404. Upload reports still contain unmapped files; original app TypeScript mapping and runtime-ID association are established by build artifacts/fixtures, while real event deobfuscation remains rollout QA. Deployment acceptance confirms this preview fits its configured function limits; exact deployed size and post-upload server contents were not independently downloaded. No package patch, dependency, or new environment variable is required. Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
401 lines
16 KiB
TypeScript
401 lines
16 KiB
TypeScript
import { execFile } from 'node:child_process'
|
|
import {
|
|
mkdir,
|
|
mkdtemp,
|
|
readdir,
|
|
readFile,
|
|
realpath,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from 'node:fs/promises'
|
|
import { createRequire } from 'node:module'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { promisify } from 'node:util'
|
|
import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'
|
|
import { nitro } from 'nitro/vite'
|
|
import { createBuilder, resolveConfig, type Plugin, type UserConfigFn } from 'vite'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import studioConfig, { sentryForFinalOutputs, serverSourceMaps } from '../../vite.config'
|
|
|
|
const captured = vi.hoisted(() => ({
|
|
nitro: undefined as Parameters<typeof nitro>[0] | undefined,
|
|
sentry: undefined as Parameters<typeof sentryTanstackStart>[0] | undefined,
|
|
sentryPlugins: [] as Plugin[],
|
|
}))
|
|
vi.mock('nitro/vite', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('nitro/vite')>()
|
|
return {
|
|
...actual,
|
|
nitro: (options: Parameters<typeof nitro>[0]) => {
|
|
captured.nitro = options
|
|
return actual.nitro(options)
|
|
},
|
|
}
|
|
})
|
|
vi.mock('@sentry/tanstackstart-react/vite', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@sentry/tanstackstart-react/vite')>()
|
|
return {
|
|
...actual,
|
|
sentryTanstackStart: (options: Parameters<typeof sentryTanstackStart>[0]) => {
|
|
captured.sentry = options
|
|
const plugins = actual.sentryTanstackStart(options)
|
|
captured.sentryPlugins = plugins
|
|
return plugins
|
|
},
|
|
}
|
|
})
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
|
|
const studioRoot = path.resolve(import.meta.dirname, '../..')
|
|
const temporaryDirs: string[] = []
|
|
const require = createRequire(path.join(studioRoot, 'package.json'))
|
|
const sentryRequire = createRequire(require.resolve('@sentry/tanstackstart-react/vite'))
|
|
const bundlerRequire = createRequire(sentryRequire.resolve('@sentry/vite-plugin'))
|
|
const {
|
|
TraceMap,
|
|
originalPositionFor,
|
|
}: {
|
|
TraceMap: new (map: SourceMap) => unknown
|
|
originalPositionFor: (
|
|
map: unknown,
|
|
position: { line: number; column: number }
|
|
) => { source: string | null; line: number | null }
|
|
} = bundlerRequire('@jridgewell/trace-mapping')
|
|
|
|
type SourceMap = {
|
|
version: 3
|
|
sources: string[]
|
|
sourcesContent: string[]
|
|
names: string[]
|
|
mappings: string
|
|
}
|
|
|
|
async function resolveStudioConfig(
|
|
hasUploadToken: boolean,
|
|
isVercel: boolean,
|
|
shouldSkipUpload = !hasUploadToken
|
|
) {
|
|
vi.stubEnv('NODE_ENV', 'production')
|
|
vi.stubEnv('SKIP_ASSET_UPLOAD', shouldSkipUpload ? '1' : '')
|
|
vi.stubEnv('SENTRY_AUTH_TOKEN', hasUploadToken ? 'test-token-never-uploaded' : '')
|
|
vi.stubEnv('SENTRY_URL', 'http://127.0.0.1:9')
|
|
vi.stubEnv('VERCEL', isVercel ? '1' : '')
|
|
const config = await (studioConfig as UserConfigFn)({ command: 'build', mode: 'production' })
|
|
return resolveConfig(
|
|
{ ...config, configFile: false, root: studioRoot, logLevel: 'silent' },
|
|
'build'
|
|
)
|
|
}
|
|
|
|
async function mapFiles(dir: string): Promise<string[]> {
|
|
const entries = await readdir(dir, { withFileTypes: true })
|
|
const files = await Promise.all(
|
|
entries.map(async (entry) => {
|
|
const file = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) return mapFiles(file)
|
|
return entry.name.endsWith('.map') ? [file] : []
|
|
})
|
|
)
|
|
return files.flat()
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs()
|
|
await Promise.all(temporaryDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
|
})
|
|
|
|
describe('server source maps', () => {
|
|
it('filters only intermediate build injection while preserving SDK SSR instrumentation plugins', async () => {
|
|
await resolveStudioConfig(true, false)
|
|
const plugins = sentryForFinalOutputs({ ...captured.sentry, autoInstrumentMiddleware: true })
|
|
const sdkPlugins = captured.sentryPlugins
|
|
expect(plugins.map((plugin) => plugin.name)).toContain('sentry-tanstackstart-route-patterns')
|
|
expect(plugins.map((plugin) => plugin.name)).toContain(
|
|
'sentry-tanstack-middleware-auto-instrument'
|
|
)
|
|
for (const plugin of sdkPlugins) {
|
|
if (plugin.name !== 'sentry-vite-plugin') expect(plugins).toContain(plugin)
|
|
}
|
|
const injection = plugins.find((plugin) => plugin.name === 'sentry-vite-plugin')
|
|
if (!injection?.applyToEnvironment) throw new Error('Expected Sentry output environment filter')
|
|
for (const name of ['client', 'nitro']) {
|
|
expect(
|
|
await injection.applyToEnvironment({
|
|
name,
|
|
getTopLevelConfig: () => ({ command: 'build' }),
|
|
} as never)
|
|
).toBe(true)
|
|
}
|
|
expect(
|
|
await injection.applyToEnvironment({
|
|
name: 'ssr',
|
|
getTopLevelConfig: () => ({ command: 'build' }),
|
|
} as never)
|
|
).toBe(false)
|
|
expect(
|
|
await injection.applyToEnvironment({
|
|
name: 'ssr',
|
|
getTopLevelConfig: () => ({ command: 'serve' }),
|
|
} as never)
|
|
).toBe(true)
|
|
})
|
|
|
|
it('loads only adjacent maps inside the resolved SSR output and propagates I/O failures', async () => {
|
|
const root = await realpath(await mkdtemp(path.join(tmpdir(), 'studio-source-map-loader-')))
|
|
temporaryDirs.push(root)
|
|
const config = await resolveStudioConfig(false, false)
|
|
config.environments.ssr.build.outDir = path.join(root, 'ssr')
|
|
await mkdir(config.environments.ssr.build.outDir)
|
|
const plugin = serverSourceMaps()
|
|
expect(plugin.apply).toBe('build')
|
|
if (
|
|
typeof plugin.applyToEnvironment !== 'function' ||
|
|
typeof plugin.configResolved !== 'function' ||
|
|
typeof plugin.load !== 'function'
|
|
) {
|
|
throw new Error('Expected source-map environment, config, and load hooks')
|
|
}
|
|
expect(plugin.applyToEnvironment({ name: 'client' } as never)).toBe(false)
|
|
expect(plugin.applyToEnvironment({ name: 'ssr' } as never)).toBe(false)
|
|
expect(plugin.applyToEnvironment({ name: 'nitro' } as never)).toBe(true)
|
|
await plugin.configResolved.call({} as never, config)
|
|
const codeFile = path.join(config.environments.ssr.build.outDir, 'entry.mjs')
|
|
const map = JSON.stringify({
|
|
version: 3,
|
|
sources: [],
|
|
sourcesContent: [],
|
|
names: [],
|
|
mappings: '',
|
|
})
|
|
await writeFile(codeFile, 'export const marker = 1')
|
|
await writeFile(`${codeFile}.map`, map)
|
|
expect(await plugin.load.call({} as never, codeFile)).toEqual({
|
|
code: 'export const marker = 1',
|
|
map,
|
|
})
|
|
expect(await plugin.load.call({} as never, path.join(root, 'unrelated.mjs'))).toBeUndefined()
|
|
expect(
|
|
await plugin.load.call({} as never, path.join(root, 'ssr-other/entry.mjs'))
|
|
).toBeUndefined()
|
|
expect(await plugin.load.call({} as never, `${codeFile}.map`)).toBeUndefined()
|
|
expect(await plugin.load.call({} as never, path.join(root, 'ssr/missing.mjs'))).toBeUndefined()
|
|
await rm(`${codeFile}.map`)
|
|
await mkdir(`${codeFile}.map`)
|
|
await expect(plugin.load.call({} as never, codeFile)).rejects.toMatchObject({ code: 'EISDIR' })
|
|
})
|
|
|
|
it.each([
|
|
[false, false, true],
|
|
[false, true, true],
|
|
[true, false, false],
|
|
[true, true, false],
|
|
[false, false, false],
|
|
[true, false, true],
|
|
])(
|
|
'enables both server stages with token=%s, Vercel=%s, skip=%s',
|
|
async (hasToken, isVercel, shouldSkipUpload) => {
|
|
const config = await resolveStudioConfig(hasToken, isVercel, shouldSkipUpload)
|
|
expect(config.environments.ssr.build.sourcemap).toBe(true)
|
|
expect(config.environments.nitro.build.sourcemap).toBe(true)
|
|
expect(config.environments.client.build.sourcemap).toBe(
|
|
hasToken && !shouldSkipUpload ? 'hidden' : false
|
|
)
|
|
expect(captured.nitro?.experimental?.sourcemapMinify).toBe(false)
|
|
expect(captured.sentry?.sourcemaps?.filesToDeleteAfterUpload).toEqual([
|
|
path.join(studioRoot, '.output/public/**/*.map'),
|
|
path.join(studioRoot, '.vercel/output/static/**/*.map'),
|
|
...(hasToken && !shouldSkipUpload
|
|
? [
|
|
path.join(studioRoot, '.output/server/**/*.map'),
|
|
path.join(studioRoot, '.vercel/output/functions/**/*.map'),
|
|
]
|
|
: []),
|
|
])
|
|
}
|
|
)
|
|
|
|
it.each([
|
|
[false, false, false],
|
|
[true, false, false],
|
|
[true, false, true],
|
|
[false, true, false],
|
|
])(
|
|
'composes original TS before cleanup with instrumentation=%s, malformed map=%s, Vercel=%s',
|
|
async (hasInstrumentation, hasMalformedMap, isVercel) => {
|
|
const config = await resolveStudioConfig(hasInstrumentation, isVercel)
|
|
vi.stubEnv('VERCEL_IMMUTABLE_STATIC_FILES_ENABLED', '1')
|
|
vi.stubEnv('VERCEL_HASH_SALT', 'source-map-fixture')
|
|
const nitroOptions = captured.nitro!
|
|
const sentryOptions = captured.sentry!
|
|
const cleanupGlobs = sentryOptions.sourcemaps?.filesToDeleteAfterUpload
|
|
if (!Array.isArray(cleanupGlobs))
|
|
throw new Error('Expected explicit source-map cleanup globs')
|
|
const root = await realpath(await mkdtemp(path.join(tmpdir(), 'studio-source-maps-')))
|
|
temporaryDirs.push(root)
|
|
await mkdir(path.join(root, 'node_modules'))
|
|
await symlink(
|
|
await realpath(path.join(studioRoot, 'node_modules/nitro')),
|
|
path.join(root, 'node_modules/nitro'),
|
|
'dir'
|
|
)
|
|
await writeFile(
|
|
path.join(root, 'index.html'),
|
|
'<html><body><!--ssr-outlet--><script type="module" src="/client.ts"></script></body></html>'
|
|
)
|
|
await writeFile(path.join(root, 'client.ts'), 'console.log("source-map-fixture")')
|
|
const originalFile = path.join(studioRoot, 'pages/api/get-deployment-commit.ts')
|
|
const originalSource = await readFile(originalFile, 'utf8')
|
|
await writeFile(
|
|
path.join(root, 'server.ts'),
|
|
`import handler from ${JSON.stringify(originalFile)};\nexport default {fetch() {return new Response(handler.name)}};\n`
|
|
)
|
|
const generatedAppMaps: { fileName: string; code: string; map: SourceMap }[] = []
|
|
const builder = await createBuilder({
|
|
configFile: false,
|
|
root,
|
|
logLevel: 'silent',
|
|
build: { sourcemap: config.environments.client.build.sourcemap },
|
|
plugins: [
|
|
serverSourceMaps(),
|
|
{
|
|
name: 'split-ssr-service-fixture',
|
|
configEnvironment(name) {
|
|
if (name !== 'ssr') return
|
|
return {
|
|
build: {
|
|
rolldownOptions: {
|
|
output: {
|
|
codeSplitting: {
|
|
groups: [{ name: 'api', test: (id) => id === originalFile }],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
nitro({
|
|
...nitroOptions,
|
|
rootDir: root,
|
|
preset: isVercel ? 'vercel' : 'node',
|
|
traceDeps: [],
|
|
modules: [],
|
|
experimental: {
|
|
...nitroOptions.experimental,
|
|
vite: { services: { ssr: { entry: path.join(root, 'server.ts') } } },
|
|
},
|
|
}),
|
|
{
|
|
name: 'capture-composed-server-map',
|
|
applyToEnvironment: (environment) => environment.name === 'nitro',
|
|
generateBundle(_options, bundle) {
|
|
for (const chunk of Object.values(bundle)) {
|
|
if (chunk.type !== 'chunk' || !chunk.map) continue
|
|
const map: SourceMap = JSON.parse(JSON.stringify(chunk.map))
|
|
if (
|
|
map.sources.some((source) =>
|
|
source.endsWith('/pages/api/get-deployment-commit.ts')
|
|
)
|
|
) {
|
|
generatedAppMaps.push({ fileName: chunk.fileName, code: chunk.code, map })
|
|
}
|
|
}
|
|
},
|
|
},
|
|
...(hasMalformedMap
|
|
? [
|
|
{
|
|
name: 'malformed-ssr-map-fixture',
|
|
applyToEnvironment: (environment: { name: string }) => environment.name === 'ssr',
|
|
async writeBundle() {
|
|
const maps = await mapFiles(
|
|
path.join(root, 'node_modules/.nitro/vite/services/ssr')
|
|
)
|
|
await Promise.all(maps.map((map) => writeFile(map, '{invalid map')))
|
|
},
|
|
},
|
|
]
|
|
: []),
|
|
...sentryForFinalOutputs({
|
|
...sentryOptions,
|
|
telemetry: false,
|
|
release: { name: 'source-map-test', create: false, finalize: false },
|
|
sourcemaps: {
|
|
...sentryOptions.sourcemaps,
|
|
// Exercise debug-ID injection and cleanup without any network upload.
|
|
disable: hasInstrumentation ? 'disable-upload' : true,
|
|
filesToDeleteAfterUpload: cleanupGlobs.map((glob) => glob.replace(studioRoot, root)),
|
|
},
|
|
}),
|
|
],
|
|
})
|
|
if (hasMalformedMap) {
|
|
await expect(builder.buildApp()).rejects.toThrow(/JSON|parse|source.?map/i)
|
|
return
|
|
}
|
|
await builder.buildApp()
|
|
const serverDir = isVercel ? '.vercel/output/functions' : '.output/server'
|
|
const publicDir = isVercel ? '.vercel/output/static' : '.output/public'
|
|
const serverMaps = await mapFiles(path.join(root, serverDir))
|
|
expect(generatedAppMaps.length).toBeGreaterThan(0)
|
|
for (const { fileName, code, map } of generatedAppMaps) {
|
|
const sourceIndex = map.sources.findIndex((source) =>
|
|
source.endsWith('/pages/api/get-deployment-commit.ts')
|
|
)
|
|
expect(map.sourcesContent[sourceIndex]).toBe(originalSource)
|
|
const marker = 'Failed to fetch commit details'
|
|
const markerIndex = code.indexOf(marker)
|
|
expect(markerIndex).toBeGreaterThan(-1)
|
|
const beforeMarker = code.slice(0, markerIndex).split('\n')
|
|
const position = originalPositionFor(new TraceMap(map), {
|
|
line: beforeMarker.length,
|
|
column: beforeMarker.at(-1)!.length,
|
|
})
|
|
expect(position.source).toBe(map.sources[sourceIndex])
|
|
expect(position.line).toBe(
|
|
originalSource.slice(0, originalSource.indexOf(marker)).split('\n').length
|
|
)
|
|
if (hasInstrumentation) {
|
|
const finalFile = path.join(
|
|
root,
|
|
isVercel ? '.vercel/output/functions/__server.func' : '.output/server',
|
|
fileName
|
|
)
|
|
const uploadDebugId = code.match(
|
|
/sentry-dbid-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/
|
|
)?.[1]
|
|
expect(uploadDebugId).toBeDefined()
|
|
const { stdout } = await execFileAsync(process.execPath, [
|
|
'--input-type=module',
|
|
'--eval',
|
|
`
|
|
import { createRequire } from 'node:module';
|
|
const require = createRequire(${JSON.stringify(sentryRequire.resolve('@sentry/core'))});
|
|
const { getFilenameToDebugIdMap } = require(${JSON.stringify(sentryRequire.resolve('@sentry/core'))});
|
|
const { defaultStackParser } = require(${JSON.stringify(sentryRequire.resolve('@sentry/node'))});
|
|
await import(${JSON.stringify(pathToFileURL(finalFile).href)});
|
|
console.log(JSON.stringify(getFilenameToDebugIdMap(defaultStackParser)));
|
|
`,
|
|
])
|
|
const runtimeIds: Record<string, string> = JSON.parse(stdout)
|
|
expect(runtimeIds[finalFile] ?? runtimeIds[pathToFileURL(finalFile).href]).toBe(
|
|
uploadDebugId
|
|
)
|
|
}
|
|
}
|
|
if (hasInstrumentation) expect(serverMaps).toEqual([])
|
|
else expect(serverMaps.length).toBeGreaterThan(0)
|
|
expect(
|
|
(await mapFiles(path.join(root, 'node_modules/.nitro/vite/services/ssr'))).length
|
|
).toBeGreaterThan(0)
|
|
expect(await mapFiles(path.join(root, publicDir))).toEqual([])
|
|
},
|
|
30_000
|
|
)
|
|
})
|