turbopack_core/source_map/
utils.rs1use 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#[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 #[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 mappings: Box<RawValue>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 ignore_list: Option<Box<RawValue>>,
70
71 #[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
79pub 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 let maybe_file_url = if source_url.starts_with("//") {
101 Cow::Owned(format!("file:/{source_url}"))
104 } else if source_url.starts_with('/') {
105 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 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 return Ok(());
124 }
125 } else {
126 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 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 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: ®ex::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 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
233async 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
259pub 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 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
279pub 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 None,
364 [
365 "page.js",
366 "./current-dir-page.js",
367 "../other%20route/page.js",
368 url_root.join("absolute%20file%20url.js")?.as_str(),
370 &format!("{}/server%20relative%20path.js", url_root.path()),
373 url_root
375 .join("scheme%20relative%20path.js")?
376 .as_str()
377 .strip_prefix("file:")
378 .unwrap(),
379 "https://example.com/page%20path.js",
381 ],
382 )),
383 &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 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}