diff --git a/.changeset/brave-files-cheat.md b/.changeset/brave-files-cheat.md new file mode 100644 index 000000000000..a351ee978454 --- /dev/null +++ b/.changeset/brave-files-cheat.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: support sourcemaps in production diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index b3d137e885bd..c5d8b4875cd5 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -1,7 +1,6 @@ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { SourceMap } from 'node:module'; -import { dirname, join, relative, resolve as resolve_path } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { mkdirp, walk } from '../../utils/filesystem.js'; import { posixify } from '../../utils/os.js'; import { noop } from '../../utils/functions.js'; @@ -17,7 +16,6 @@ import * as devalue from 'devalue'; import { createReadableStream } from '@sveltejs/kit/node'; import generate_fallback from './fallback.js'; import { stringify_remote_arg } from '../../runtime/shared.js'; -import { SRC_ROOT } from '../../constants.js'; import { log_response } from '../../exports/vite/utils.js'; export default forked(import.meta.url, prerender); @@ -50,7 +48,6 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env, vit // essential we do this before analysing the code internal.set_building(); internal.set_prerendering(); - internal.set_fix_stack_trace(adjust_stack_trace); // `set_env` and `Server` live in modules that import the user's `src/env` config. We import them // *after* `set_building()` so that `building`-dependent expressions resolve correctly @@ -119,114 +116,6 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env, vit } } - /** @type {Map} */ - const source_maps = new Map(); - /** @type {Map>} */ - const source_regions = new Map(); - - /** @param {string} file */ - function get_source_map(file) { - if (source_maps.has(file)) return source_maps.get(file); - - let source; - let directory = dirname(file); - try { - const code = readFileSync(file, 'utf8'); - const matches = Array.from(code.matchAll(/\/\/[#@]\s*sourceMappingURL=(\S+)/g)); - const url = matches.at(-1)?.[1]; - - if (url?.startsWith('data:')) { - const comma = url.indexOf(','); - const metadata = url.slice(5, comma); - const data = url.slice(comma + 1); - source = metadata.endsWith(';base64') - ? Buffer.from(data, 'base64').toString() - : decodeURIComponent(data); - } else { - const map_file = url ? resolve_path(dirname(file), decodeURIComponent(url)) : `${file}.map`; - if (existsSync(map_file)) { - directory = dirname(map_file); - source = readFileSync(map_file, 'utf8'); - } - } - - const source_map = source ? { map: new SourceMap(JSON.parse(source)), directory } : null; - source_maps.set(file, source_map); - return source_map; - } catch { - source_maps.set(file, null); - return null; - } - } - - /** @param {unknown} error */ - function adjust_stack_trace(error) { - if (!(error instanceof Error) || !error.stack) return; - - error.stack = error.stack - .split('\n') - .map((line) => { - const match = line.match(/(file:\/\/\/.*):(\d+):(\d+)(\)?)$/); - if (!match) return line; - - const file = fileURLToPath(match[1]); - // Filter out internal stack trace lines - if (file.includes('/svelte/src/internal/server/') || file.startsWith(SRC_ROOT)) { - return null; - } - // Only annotate files from our own server directory - if (!file.startsWith(`${out}/server/`)) return line; - - const source_map = get_source_map(file); - const entry = source_map?.map.findEntry(Number(match[2]) - 1, Number(match[3]) - 1); - if ( - source_map && - entry && - 'originalSource' in entry && - entry.originalSource && - typeof entry.originalLine === 'number' && - typeof entry.originalColumn === 'number' - ) { - const source = entry.originalSource.startsWith('file:') - ? fileURLToPath(entry.originalSource) - : resolve_path(source_map.directory, entry.originalSource); - - // Filter out internal stack trace lines (gotta do this again here on the original) - if (source.includes('/svelte/src/internal/server/') || source.startsWith(SRC_ROOT)) { - return null; - } - - const location = `${match[1]}:${match[2]}:${match[3]}`; - const original = `${posixify(relative(vite_config.root, source))}:${entry.originalLine + 1}:${entry.originalColumn + 1}`; - - return line.replace(location, original); - } - - let regions = source_regions.get(file); - if (!regions) { - /** @type {string | undefined} */ - let source; - regions = readFileSync(file, 'utf8') - .split('\n') - .map((line) => { - // Vite will merge multiple files into one but add region markers - // with the original file names, which we try to extract here. - const start = line.match(/^\/\/#region (.+)$/); - if (start) source = start[1]; - if (line === '//#endregion') source = undefined; - return source; - }); - source_regions.set(file, regions); - } - - const source = regions[Number(match[2]) - 1]; - // Output is something like "at file:///... [src/routes/+page.svelte]" - return source ? `${line} [${source}]` : line; - }) - .filter(Boolean) - .join('\n'); - } - const OK = 2; const REDIRECT = 3; diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 4f188cff3f49..1ba25c2cf3ba 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -401,6 +401,14 @@ function kit({ svelte_config }) { // dev and preview config can be shared /** @type {UserConfig} */ const new_config = { + environments: { + ssr: { + build: { + sourcemap: + config.environments?.ssr?.build?.sourcemap ?? config.build?.sourcemap ?? true + } + } + }, resolve: { alias: [ { find: '__SERVER__', replacement: `${generated}/server` }, @@ -1543,15 +1551,6 @@ function kit({ svelte_config }) { }, async buildApp(builder) { - const sourcemap = builder.config.build.sourcemap; - const ssr_sourcemap = builder.environments.ssr.config.build.sourcemap; - const temporary_sourcemap = !(ssr_sourcemap ?? sourcemap); - - if (temporary_sourcemap) { - // Temporarily override so that we get better stack traces for prerendering errors - builder.environments.ssr.config.build.sourcemap = 'hidden'; - } - // clears the output directories if (!builder.config.build.watch) { rimraf(out); @@ -1860,16 +1859,8 @@ function kit({ svelte_config }) { // Unforeseen error, rethrow as-is throw e; } - } finally { - if (temporary_sourcemap) { - // If we did override it, set it back to false and remove the sourcemaps - builder.environments.ssr.config.build.sourcemap = ssr_sourcemap; - - for (const file of fs.globSync(`${out}/server/**/*.js.map`)) { - fs.rmSync(file); - } - } } + prerendered = prerender_results.prerendered; await treeshake_prerendered_remotes( diff --git a/packages/kit/src/runtime/server/internal.js b/packages/kit/src/runtime/server/internal.js index 4d1d08e7684a..fdcae2d40034 100644 --- a/packages/kit/src/runtime/server/internal.js +++ b/packages/kit/src/runtime/server/internal.js @@ -1,11 +1,5 @@ /** @import { SSRManifest } from '@sveltejs/kit'; */ -/** - * @param {Error} _error - * @returns void - */ -export let fix_stack_trace = (_error) => {}; - /** * @type {((path: string) => ReadableStream) | null} */ @@ -13,13 +7,6 @@ export let read_implementation = null; export let manifest = /** @type {SSRManifest} */ (/** @type {unknown} */ (null)); -/** - * @param {(error: Error) => void} fn - */ -export function set_fix_stack_trace(fn) { - fix_stack_trace = fn; -} - /** * @param {(path: string) => ReadableStream} fn */ @@ -34,3 +21,5 @@ export function set_read_implementation(fn) { export function set_manifest(value) { manifest = value; } + +export { fix_stack_trace, set_fix_stack_trace } from './sourcemaps.js'; diff --git a/packages/kit/src/runtime/server/sourcemaps.js b/packages/kit/src/runtime/server/sourcemaps.js new file mode 100644 index 000000000000..c81b7cc9aeb6 --- /dev/null +++ b/packages/kit/src/runtime/server/sourcemaps.js @@ -0,0 +1,183 @@ +/* eslint-disable n/prefer-global/process */ + +// using `getBuiltinModule` rather than `import` makes this safe to run in non-Node-compatible environments +const fs = globalThis.process?.getBuiltinModule?.('node:fs'); +const url = globalThis.process?.getBuiltinModule?.('node:url'); +const path = globalThis.process?.getBuiltinModule?.('node:path'); +const module = globalThis.process?.getBuiltinModule?.('node:module'); + +const cwd = globalThis.process?.cwd?.(); + +/** @type {(file: string) => string} */ +const relative = cwd ? (file) => path.relative(cwd, file) : (file) => file; + +/** + * Applies sourcemaps, makes paths relative to the cwd, and truncates + * non-user code from the bottom of the stack + * @param {Error} error + * @returns void + */ +export let fix_stack_trace = (error) => { + if (!error.stack || !fs) return; + + let end = 0; + + error.stack = error.stack + .split('\n') + .map((line, i) => { + const match = line.match(/^ {4}at.+(file:\/\/\/.*):(\d+):(\d+)(\)?)$/); + + if (!match) { + if (!line.includes('node:internal/')) { + end = i + 1; + } + + return line; + } + + const file = url.fileURLToPath(match[1]); + const traced = trace(file, Number(match[2]) - 1, Number(match[3]) - 1); + + // truncate non-user code from the bottom of the stack (but leave it in otherwise) + if (!/[\\/]node_modules[\\/]/.test(traced?.file ?? file)) { + end = i + 1; + } + + if (traced?.line) { + const location = `${match[1]}:${match[2]}:${match[3]}`; + const original = `${relative(traced.file)}:${traced.line}:${traced.column}`; + + return line.replace(location, original); + } + + if (traced) { + return `${line.replace(match[1], relative(file))} [${traced.file}]`; + } + + return line; + }) + .slice(0, end) + .join('\n'); +}; + +/** + * Override the implementation of fix_stack_trace (for using during dev) + * @param {(error: Error) => void} fn + */ +export function set_fix_stack_trace(fn) { + fix_stack_trace = fn; +} + +/** @type {Map} */ +const source_maps = new Map(); + +/** @type {Map>} */ +const source_regions = new Map(); + +/** @param {string} file */ +function get_source_map(file) { + if (source_maps.has(file)) { + return source_maps.get(file); + } + + try { + let source; + let directory = path.dirname(file); + + const code = fs.readFileSync(file, 'utf8'); + const matches = Array.from(code.matchAll(/\/\/[#@]\s*sourceMappingURL=(\S+)/g)); + const url = matches.at(-1)?.[1]; + + if (url?.startsWith('data:')) { + const comma = url.indexOf(','); + const metadata = url.slice(5, comma); + const data = url.slice(comma + 1); + source = metadata.endsWith(';base64') + ? Buffer.from(data, 'base64').toString() + : decodeURIComponent(data); + } else { + const map_file = url + ? path.resolve(path.dirname(file), decodeURIComponent(url)) + : `${file}.map`; + if (fs.existsSync(map_file)) { + directory = path.dirname(map_file); + source = fs.readFileSync(map_file, 'utf8'); + } + } + + if (source) { + const source_map = { map: new module.SourceMap(JSON.parse(source)), directory }; + source_maps.set(file, source_map); + + return source_map; + } + } catch { + // failure could be for any reason and this is best-effort, ignore + } + + source_maps.set(file, null); + return null; +} + +/** + * + * @param {string} file + * @param {number} line + * @param {number} column + * @returns {null | { file: string, line?: number, column?: number }} + */ +function trace(file, line, column) { + const source_map = get_source_map(file); + if (!source_map) return null; + + const entry = source_map.map.findEntry(line, column); + + if ( + entry && + 'originalSource' in entry && + entry.originalSource && + typeof entry.originalLine === 'number' && + typeof entry.originalColumn === 'number' + ) { + const source = entry.originalSource.startsWith('file:') + ? url.fileURLToPath(entry.originalSource) + : path.resolve(source_map.directory, entry.originalSource); + + const traced = { + file: source, + line: entry.originalLine + 1, + column: entry.originalColumn + 1 + }; + + // keep going, in case we are running code that was bundled a second time by an adapter + return trace(traced.file, traced.line - 1, traced.column - 1) ?? traced; + } + + let regions = source_regions.get(file); + if (!regions) { + /** @type {string | undefined} */ + let source; + regions = fs + .readFileSync(file, 'utf8') + .split('\n') + .map((line) => { + // Vite will merge multiple files into one but add region markers + // with the original file names, which we try to extract here. + const start = line.match(/^\/\/#region (.+)$/); + if (start) source = start[1]; + if (line === '//#endregion') source = undefined; + return source; + }); + source_regions.set(file, regions); + } + + const source = regions[line]; + + if (source) { + return { + file: source + }; + } + + return null; +}