Skip to main content

turbopack_core/source_map/
utils.rs

1use std::{borrow::Cow, collections::HashSet, iter, sync::LazyLock};
2
3use anyhow::{Context, Result};
4use const_format::concatcp;
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use serde_json::value::RawValue;
8use turbo_rcstr::RcStr;
9use turbo_tasks::{ResolvedVc, turbofmt};
10use turbo_tasks_fs::{DiskFileSystem, FileContent, FileSystemPath, rope::Rope};
11use url::Url;
12
13use crate::{SOURCE_URL_PROTOCOL_STR, source_map::structured::StructuredSourceMap};
14
15pub fn add_default_ignore_list(map: &mut swc_sourcemap::SourceMap) {
16    let mut ignored_ids = HashSet::new();
17
18    for (source_id, source) in map.sources().enumerate() {
19        if source.starts_with(concatcp!(SOURCE_URL_PROTOCOL_STR, "///[next]"))
20            || source.starts_with(concatcp!(SOURCE_URL_PROTOCOL_STR, "///[turbopack]"))
21            || source.contains("/node_modules/")
22            || source.ends_with("__nextjs-internal-proxy.cjs")
23            || source.ends_with("__nextjs-internal-proxy.mjs")
24        {
25            ignored_ids.insert(source_id);
26        }
27    }
28
29    for ignored_id in ignored_ids {
30        map.add_to_ignore_list(ignored_id as _);
31    }
32}
33
34#[derive(Serialize, Deserialize)]
35struct SourceMapSectionOffsetJson {
36    line: u32,
37    offset: u32,
38}
39
40#[derive(Serialize, Deserialize)]
41struct SourceMapSectionItemJson {
42    offset: SourceMapSectionOffsetJson,
43    map: SourceMapJson,
44}
45
46// Some of these values use `Box<RawValue>`: If we don't read these fields (or rarely read these
47// fields) there's no point in decoding/encoding the data. Ideally they would be a `&RawValue`
48// reference, but we deserialize using `from_reader`, which does not support that.
49#[derive(Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51struct SourceMapJson {
52    version: u32,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    file: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    source_root: Option<String>,
57    // Technically a required field, but we don't want to error here.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    sources: Option<Vec<Option<String>>>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    sources_content: Option<Vec<Option<Box<RawValue>>>>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    names: Option<Box<RawValue>>,
64    // We just need to hold onto `mappings` for serialization/deserialization, so there's no point
65    // in decoding/encoding the string. Store it as a `RawValue`. Ideally this would be a reference
66    // to the RawValue, but we deserialize using `from_reader`, which does not support that.
67    mappings: Box<RawValue>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    ignore_list: Option<Box<RawValue>>,
70
71    // A somewhat widespread non-standard extension
72    #[serde(skip_serializing_if = "Option::is_none")]
73    debug_id: Option<Box<RawValue>>,
74
75    #[serde(skip_serializing_if = "Option::is_none")]
76    sections: Option<Vec<SourceMapSectionItemJson>>,
77}
78
79/// Replace the origin prefix in the `file` and `sources` with `turbopack:///` and read the
80/// `sourceContent`s from disk.
81pub async fn resolve_source_map_sources(
82    map: Option<&Rope>,
83    origin: &FileSystemPath,
84) -> Result<Option<Rope>> {
85    let fs_vc = origin.fs().to_resolved().await?;
86    let fs_str = &*turbofmt!("[{fs_vc}]").await?;
87
88    let disk_fs = if let Some(fs_vc) = ResolvedVc::try_downcast_type::<DiskFileSystem>(fs_vc) {
89        Some((fs_vc, fs_vc.await?))
90    } else {
91        None
92    };
93    let disk_fs = &disk_fs;
94
95    let resolve_source =
96        async |source_url: &mut String, source_content: Option<&mut Option<Box<RawValue>>>| {
97            // original_source should always be a URL (possibly a `file://` url). If it's a relative
98            // URL, it should be relative to `origin` (the generated file that's being mapped).
99            // https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Web_mechanics/What_is_a_URL#absolute_urls_vs._relative_urls
100            let maybe_file_url = if source_url.starts_with("//") {
101                // looks like a "scheme-relative" URL
102                // Rewrite '//scheme/relative' -> 'file:///scheme/relative' (three slashes)
103                Cow::Owned(format!("file:/{source_url}"))
104            } else if source_url.starts_with('/') {
105                // looks like a "domain-relative" (aka "server-relative") URL
106                // Rewrite '/domain/relative' -> 'file:///domain/relative' (three slashes)
107                Cow::Owned(format!("file://{source_url}"))
108            } else {
109                Cow::Borrowed(source_url)
110            };
111
112            let fs_path = if let Ok(original_source_url_obj) = Url::parse(&maybe_file_url) {
113                // We have an absolute URL, try to parse it as a `file://` URL
114                if let Ok(sys_path) = original_source_url_obj.to_file_path() {
115                    if let Some((disk_fs_vc, disk_fs)) = disk_fs {
116                        disk_fs.try_from_sys_path(*disk_fs_vc, &sys_path, Some(origin))
117                    } else {
118                        None
119                    }
120                } else {
121                    // this is an absolute URL with a non-`file://` scheme, just assume it's valid
122                    // and don't modify anything
123                    return Ok(());
124                }
125            } else {
126                // assume it's a relative URL, and just remove any percent encoding from path
127                // segments. Our internal path format is POSIX-like, without percent encoding.
128                origin
129                    .parent()
130                    .try_join(&urlencoding::decode(source_url).unwrap_or(Cow::Borrowed(source_url)))
131            };
132
133            if let Some(fs_path) = fs_path {
134                // TODO: Encode `fs_str` and `fs_path_str` using `urlencoding`, so that these are
135                // valid URLs. However, `project_trace_source_operation` (and `uri_from_file`) need
136                // to handle percent encoding correctly first.
137                let fs_path_str = &fs_path.path;
138                *source_url = format!("{SOURCE_URL_PROTOCOL_STR}///{fs_str}/{fs_path_str}");
139
140                if let Some(source_content) = source_content
141                    && source_content.is_none()
142                {
143                    if let FileContent::Content(file) = &*fs_path.read().await? {
144                        let text = file.content().to_str()?;
145                        *source_content = Some(unencoded_str_to_raw_value(&text));
146                    } else {
147                        *source_content = Some(unencoded_str_to_raw_value(&format!(
148                            "unable to read source {fs_str}/{fs_path_str}"
149                        )));
150                    }
151                }
152            } else {
153                // The URL was broken somehow, create a dummy `turbopack://` URL and content
154                let origin_str = &origin.path;
155                if let Some(source_content) = source_content
156                    && source_content.is_none()
157                {
158                    *source_content = Some(unencoded_str_to_raw_value(&format!(
159                        "unable to access {source_url} in {fs_str}/{origin_str} (it's leaving the \
160                         filesystem root)"
161                    )));
162                }
163                static INVALID_REGEX: LazyLock<Regex> =
164                    LazyLock::new(|| Regex::new(r#"(?:^|/)(?:\.\.?(?:/|$))+"#).unwrap());
165                let source = INVALID_REGEX
166                    .replace_all(source_url, |s: &regex::Captures<'_>| s[0].replace('.', "_"));
167                *source_url = format!("{SOURCE_URL_PROTOCOL_STR}///{fs_str}/{origin_str}/{source}");
168            }
169            anyhow::Ok(())
170        };
171
172    let resolve_map = async |map: &mut SourceMapJson| {
173        if let Some(sources) = &mut map.sources {
174            let mut contents = if let Some(mut contents) = map.sources_content.take() {
175                contents.resize(sources.len(), None);
176                contents
177            } else {
178                iter::repeat_n(None, sources.len()).collect()
179            };
180
181            for (source, content) in sources.iter_mut().zip(contents.iter_mut()) {
182                if let Some(source) = source {
183                    if let Some(source_root) = &map.source_root {
184                        *source = format!("{source_root}{source}");
185                    }
186                    resolve_source(source, Some(content)).await?;
187                }
188            }
189
190            map.source_root = None;
191            map.sources_content = Some(contents);
192        }
193        anyhow::Ok(())
194    };
195
196    let Some(map) = map else {
197        return Ok(None);
198    };
199
200    let Ok(mut map): serde_json::Result<SourceMapJson> = serde_json::from_reader(map.read()) else {
201        // Silently ignore invalid sourcemaps
202        return Ok(None);
203    };
204
205    if let Some(file) = &mut map.file {
206        resolve_source(file, None).await?;
207    }
208
209    resolve_map(&mut map).await?;
210    for section in map.sections.iter_mut().flatten() {
211        resolve_map(&mut section.map).await?;
212    }
213
214    let map = Rope::from(serde_json::to_vec(&map)?);
215    Ok(Some(map))
216}
217
218fn unencoded_str_to_raw_value(unencoded: &str) -> Box<RawValue> {
219    RawValue::from_string(
220        serde_json::to_string(unencoded)
221            .expect("serialization of a utf-8 string should always succeed"),
222    )
223    .expect("serde_json::to_string should produce valid JSON")
224}
225
226fn uri_encode_path(path: &str) -> String {
227    path.split('/')
228        .map(|s| urlencoding::encode(s))
229        .collect::<Vec<_>>()
230        .join("/")
231}
232
233/// Applies the standard `turbopack:///[fs]/` source-URL transform to a structured map,
234/// sharing every other field (notably `sourcesContent`) with the input.
235async fn transform_relative_files<F>(
236    map: &StructuredSourceMap,
237    context_path: &FileSystemPath,
238    mut transform: F,
239) -> Result<StructuredSourceMap>
240where
241    F: FnMut(&DiskFileSystem, &str) -> Result<String>,
242{
243    let context_fs = context_path.fs;
244    let context_fs = &*ResolvedVc::try_downcast_type::<DiskFileSystem>(context_fs)
245        .context("Expected the chunking context to have a DiskFileSystem")?
246        .await?;
247
248    let prefix = format!("{}///[{}]/", SOURCE_URL_PROTOCOL_STR, context_fs.name());
249
250    map.rewrite_sources(|src| {
251        if let Some(src_rest) = src.strip_prefix(&prefix) {
252            Ok(Some(transform(context_fs, src_rest)?))
253        } else {
254            Ok(None)
255        }
256    })
257}
258
259/// Turns `turbopack:///[project]` references in the map's sources into absolute `file://` uris.
260pub async fn absolute_fileify_source_map(
261    map: &StructuredSourceMap,
262    context_path: FileSystemPath,
263) -> Result<StructuredSourceMap> {
264    transform_relative_files(map, &context_path.clone(), |context_fs, src_rest| {
265        let path = context_path.join(src_rest)?;
266
267        // `to_sys_path` returns a win32 path on Windows. `Url::from_file_path` can also handle
268        // verbatim (`\\?\`-prefixed) disk and UNC paths, in case that conversion failed.
269        let sys_path = context_fs.to_sys_path(&path);
270        Ok(Url::from_file_path(&sys_path)
271            .map_err(|()| {
272                anyhow::anyhow!("path {sys_path:?} cannot be converted to a file:// URI")
273            })?
274            .into())
275    })
276    .await
277}
278
279/// Turns `turbopack:///[project]` references in the map's sources into `./`-relative uris.
280pub async fn relative_fileify_source_map(
281    map: &StructuredSourceMap,
282    context_path: FileSystemPath,
283    relative_path_to_output_root: RcStr,
284) -> Result<StructuredSourceMap> {
285    let relative_path_to_output_root = relative_path_to_output_root
286        .split('/')
287        .map(|s| urlencoding::encode(s))
288        .collect::<Vec<_>>()
289        .join("/");
290    transform_relative_files(map, &context_path, |_context_fs, src_rest| {
291        let src_rest = uri_encode_path(src_rest);
292        if relative_path_to_output_root.is_empty() {
293            Ok(src_rest.to_string())
294        } else {
295            Ok(format!("{relative_path_to_output_root}/{src_rest}"))
296        }
297    })
298    .await
299}
300
301#[cfg(test)]
302mod tests {
303    use std::path::Path;
304
305    use turbo_rcstr::{RcStr, rcstr};
306    use turbo_tasks::Vc;
307    use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
308    use turbo_tasks_fs::FileSystem;
309
310    use super::*;
311
312    fn source_map_rope<'a>(
313        source_root: Option<&str>,
314        sources: impl IntoIterator<Item = &'a str>,
315    ) -> Rope {
316        Rope::from(
317            serde_json::to_string_pretty(
318                &serde_json::from_value::<SourceMapJson>(serde_json::json!({
319                    "version": 3,
320                    "mappings": "",
321                    "sourceRoot": source_root,
322                    "sources": sources.into_iter().map(Some).collect::<Vec<_>>(),
323                }))
324                .unwrap(),
325            )
326            .unwrap(),
327        )
328    }
329
330    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
331    async fn test_resolve_source_map_sources() {
332        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
333            BackendOptions::default(),
334            noop_backing_storage(),
335        ));
336        tt.run_once(async move {
337            #[turbo_tasks::value]
338            struct SourceMapSourcesOutput {
339                resolved_sources: Vec<Option<String>>,
340                rooted_sources: Vec<Option<String>>,
341            }
342
343            #[turbo_tasks::function(operation, root)]
344            async fn resolve_source_map_sources_operation()
345            -> anyhow::Result<Vc<SourceMapSourcesOutput>> {
346                let sys_root = if cfg!(windows) {
347                    Path::new(r"C:\fake\root")
348                } else {
349                    Path::new(r"/fake/root")
350                };
351                let url_root = Url::from_directory_path(sys_root).unwrap();
352
353                let fs_root_path = DiskFileSystem::new(
354                    rcstr!("mock"),
355                    Vc::cell(RcStr::from(sys_root.to_str().unwrap())),
356                )
357                .root()
358                .await?;
359
360                let resolved_source_map: SourceMapJson = serde_json::from_str(
361                    &resolve_source_map_sources(
362                        Some(&source_map_rope(
363                            /* source_root */ None,
364                            [
365                                "page.js",
366                                "./current-dir-page.js",
367                                "../other%20route/page.js",
368                                // contains the file:// protocol/scheme
369                                url_root.join("absolute%20file%20url.js")?.as_str(),
370                                // A server-relative path starting with `/`, potentially includes a
371                                // windows disk
372                                &format!("{}/server%20relative%20path.js", url_root.path()),
373                                // A scheme-relative path
374                                url_root
375                                    .join("scheme%20relative%20path.js")?
376                                    .as_str()
377                                    .strip_prefix("file:")
378                                    .unwrap(),
379                                // non-file URLs are preserved
380                                "https://example.com/page%20path.js",
381                            ],
382                        )),
383                        // NOTE: the percent encoding here should NOT be decoded, as this is not
384                        // part of a `file://` URL
385                        &fs_root_path.join("app/source%20mapped/page.js").unwrap(),
386                    )
387                    .await?
388                    .unwrap()
389                    .to_str()?,
390                )?;
391
392                let rooted_source_map: SourceMapJson = serde_json::from_str(
393                    &resolve_source_map_sources(
394                        Some(&source_map_rope(
395                            // NOTE: these should get literally concated, a slash should NOT get
396                            // added.
397                            Some("../source%20root%20"),
398                            ["page.js"],
399                        )),
400                        &fs_root_path.join("app/page.js").unwrap(),
401                    )
402                    .await?
403                    .unwrap()
404                    .to_str()?,
405                )?;
406
407                Ok(SourceMapSourcesOutput {
408                    resolved_sources: resolved_source_map.sources.unwrap_or_default(),
409                    rooted_sources: rooted_source_map.sources.unwrap_or_default(),
410                }
411                .cell())
412            }
413
414            let resolved_source_maps = resolve_source_map_sources_operation()
415                .read_strongly_consistent()
416                .await?;
417
418            let prefix = format!("{SOURCE_URL_PROTOCOL_STR}///[mock]");
419            assert_eq!(
420                resolved_source_maps.resolved_sources,
421                vec![
422                    Some(format!("{prefix}/app/source%20mapped/page.js")),
423                    Some(format!("{prefix}/app/source%20mapped/current-dir-page.js")),
424                    Some(format!("{prefix}/app/other route/page.js")),
425                    Some(format!("{prefix}/absolute file url.js")),
426                    Some(format!("{prefix}/server relative path.js")),
427                    Some(format!("{prefix}/scheme relative path.js")),
428                    Some("https://example.com/page%20path.js".to_owned()),
429                ]
430            );
431
432            assert_eq!(
433                resolved_source_maps.rooted_sources,
434                vec![Some(format!("{prefix}/source root page.js"))]
435            );
436
437            anyhow::Ok(())
438        })
439        .await
440        .unwrap();
441    }
442}