1use std::{borrow::Cow, path::PathBuf, sync::Arc};
7
8use cow_utils::CowUtils;
9use futures::stream::{FuturesOrdered, StreamExt};
10use once_cell::sync::Lazy;
11use regex::Regex;
12use rspack_fs::ReadableFileSystem;
13use rspack_paths::{AssertUtf8, Utf8Path, Utf8PathBuf};
14use rspack_sources::SourceMap;
15use rspack_util::{base64, node_path::NodePath};
16use rustc_hash::FxHashSet;
17
18#[derive(Debug)]
20pub struct ExtractSourceMapResult {
21 pub source: String,
22 pub source_map: Option<SourceMap<'static>>,
23 pub file_dependencies: Option<FxHashSet<PathBuf>>,
24}
25
26#[derive(Debug)]
28pub struct SourceMappingURL {
29 pub source_mapping_url: String,
30 pub replacement_string: String,
31}
32
33static VALID_PROTOCOL_PATTERN: Lazy<Regex> =
34 Lazy::new(|| Regex::new(r"^[a-z][a-z0-9+.-]*:").expect("Invalid regex pattern"));
35static SOURCE_MAPPING_URL_REGEX: Lazy<Regex> = Lazy::new(|| {
36 Regex::new(r#"(?:/\*(?:\s*\r?\n(?://)?)?(?:\s*[#@]\s*sourceMappingURL\s*=\s*([^\s'"]*)\s*)\s*\*/|//(?:\s*[#@]\s*sourceMappingURL\s*=\s*([^\s'"]*)\s*))\s*"#).expect("Invalid regex pattern")
37});
38static URI_REGEX: Lazy<Regex> = Lazy::new(|| {
39 Regex::new(r"^data:([^;,]+)?((?:;[^;,]+)*?)(?:;(base64)?)?,(.*)$").expect("Invalid regex pattern")
40});
41
42pub fn get_source_mapping_url(code: &str) -> SourceMappingURL {
44 let mut match_result = None;
46 let mut replacement_string = String::new();
47
48 if let Some(captures) = SOURCE_MAPPING_URL_REGEX.captures_iter(code).last() {
50 match_result = captures
51 .get(1)
52 .or_else(|| captures.get(2))
53 .map(|m| m.as_str());
54
55 replacement_string = captures.get(0).map_or("", |m| m.as_str()).to_string();
57 }
58
59 let source_mapping_url = match_result.unwrap_or("").to_string();
60
61 SourceMappingURL {
62 source_mapping_url: if !source_mapping_url.is_empty() {
63 urlencoding::decode(&source_mapping_url)
64 .unwrap_or(Cow::Borrowed(&source_mapping_url))
65 .to_string()
66 } else {
67 source_mapping_url
68 },
69 replacement_string,
70 }
71}
72
73fn is_url(value: &str) -> bool {
75 VALID_PROTOCOL_PATTERN.is_match(value) && !Utf8Path::new(value).node_is_absolute_win32()
76}
77
78fn is_absolute(path: &Utf8Path) -> bool {
79 path.node_is_absolute_posix() || path.node_is_absolute_win32()
80}
81
82fn decode_data_uri(uri: &str) -> Option<String> {
84 let captures = URI_REGEX.captures(uri)?;
87 let is_base64 = captures.get(3).is_some();
88 let body = captures.get(4)?.as_str();
89
90 if is_base64 {
91 return base64::decode_to_vec(body)
92 .ok()
93 .and_then(|bytes| String::from_utf8(bytes).ok());
94 }
95
96 match urlencoding::decode(body) {
99 Ok(decoded) => Some(decoded.to_string()),
100 Err(_) => Some(body.to_string()),
101 }
102}
103
104fn fetch_from_data_url(source_url: &str) -> Result<String, String> {
106 if let Some(content) = decode_data_uri(source_url) {
107 Ok(content)
108 } else {
109 Err(format!(
110 "Failed to parse source map from \"data\" URL: {source_url}"
111 ))
112 }
113}
114
115fn get_absolute_path(context: &Utf8Path, request: &str, source_root: Option<&str>) -> Utf8PathBuf {
117 let path = if let Some(source_root) = source_root {
118 let source_root_path = Utf8Path::new(source_root);
119 if is_absolute(source_root_path) {
120 source_root_path.join(request)
121 } else {
122 context.join(source_root).join(request)
123 }
124 } else {
125 context.join(request)
126 };
127 path.node_normalize()
128}
129
130async fn fetch_from_filesystem(
132 fs: &Arc<dyn ReadableFileSystem>,
133 source_url: &str,
134) -> Result<(String, Option<String>), String> {
135 if is_url(source_url) {
136 return Ok((source_url.to_string(), None));
137 }
138
139 let path = PathBuf::from(source_url);
140 fs.read_to_string(&path.assert_utf8())
141 .await
142 .map(|content| (source_url.to_string(), Some(content)))
143 .map_err(|err| format!("Failed to parse source map from '{source_url}' file: {err}"))
144}
145
146async fn fetch_paths_from_filesystem(
148 fs: &Arc<dyn ReadableFileSystem>,
149 possible_requests: &[String],
150 mut errors_accumulator: String,
151) -> Result<(String, Option<String>), String> {
152 if possible_requests.is_empty() {
153 return Err(errors_accumulator);
154 }
155
156 for (i, request) in possible_requests.iter().enumerate() {
158 match fetch_from_filesystem(fs, request).await {
159 Ok(result) => return Ok(result),
160 Err(error) => {
161 if i > 0 {
162 errors_accumulator.push_str("\n\n");
163 }
164 errors_accumulator.push_str(&error);
165 }
166 }
167 }
168
169 Err(errors_accumulator)
170}
171
172async fn fetch_from_url(
174 fs: &Arc<dyn ReadableFileSystem>,
175 context: &Utf8Path,
176 url: &str,
177 source_root: Option<&str>,
178 skip_reading: bool,
179) -> Result<(String, Option<String>), String> {
180 if is_url(url) {
182 if url.starts_with("data:") {
183 if skip_reading {
184 return Ok((String::new(), None));
185 }
186
187 let source_content = fetch_from_data_url(url)?;
188 return Ok((String::new(), Some(source_content)));
189 }
190
191 if skip_reading {
192 return Ok((url.to_string(), None));
193 }
194
195 if url.starts_with("file:") {
196 let path_from_url = url.strip_prefix("file://").unwrap_or(url);
198 let source_url = PathBuf::from(path_from_url).to_string_lossy().into_owned();
199 return fetch_from_filesystem(fs, &source_url).await;
200 }
201
202 return Err(format!(
203 "Failed to parse source map: '{url}' URL is not supported"
204 ));
205 }
206
207 if url.starts_with("//") {
209 return Err(format!(
210 "Failed to parse source map: '{url}' URL is not supported"
211 ));
212 }
213
214 if is_absolute(Utf8Path::new(url)) {
216 let source_url = Utf8Path::new(url).node_normalize().to_string();
217
218 if !skip_reading {
219 let mut possible_requests = Vec::with_capacity(2);
220 possible_requests.push(source_url.clone());
221
222 if let Some(stripped) = url.strip_prefix('/') {
223 let absolute_path = get_absolute_path(context, stripped, source_root);
224 possible_requests.push(absolute_path.to_string());
225 }
226
227 return fetch_paths_from_filesystem(fs, &possible_requests, String::new()).await;
228 }
229
230 return Ok((source_url, None));
231 }
232
233 let source_url = get_absolute_path(context, url, source_root);
235 let source_url_str = source_url.to_string();
236
237 if !skip_reading {
238 let (_, content) = fetch_from_filesystem(fs, &source_url_str).await?;
239 return Ok((source_url_str, content));
240 }
241
242 Ok((source_url_str, None))
243}
244
245pub async fn extract_source_map(
247 fs: Arc<dyn ReadableFileSystem>,
248 input: &str,
249 resource_path: &str,
250) -> Result<ExtractSourceMapResult, String> {
251 let SourceMappingURL {
252 source_mapping_url,
253 replacement_string,
254 } = get_source_mapping_url(input);
255
256 if source_mapping_url.is_empty() {
257 return Ok(ExtractSourceMapResult {
258 source: input.to_string(),
259 source_map: None,
260 file_dependencies: None,
261 });
262 }
263
264 let base_context = Utf8Path::new(resource_path)
265 .parent()
266 .ok_or_else(|| "Invalid resource path".to_string())?;
267
268 let (source_url, source_content) =
269 fetch_from_url(&fs, base_context, &source_mapping_url, None, false).await?;
270
271 let content = match source_content.as_deref() {
272 Some(c) => c.trim_start_matches(")]}'"),
273 None => {
274 return Ok(ExtractSourceMapResult {
275 source: input.to_string(),
276 source_map: None,
277 file_dependencies: if source_url.is_empty() {
278 None
279 } else {
280 let mut set = FxHashSet::default();
281 set.insert(PathBuf::from(source_url));
282 Some(set)
283 },
284 });
285 }
286 };
287
288 let mut source_map = SourceMap::from_json(content.to_string())
290 .map_err(|e| format!("Failed to parse source map: {e}"))?;
291
292 let context = if !source_url.is_empty() {
293 Utf8Path::new(&source_url).parent().unwrap_or(base_context)
294 } else {
295 base_context
296 };
297
298 let mut resolved_sources = Vec::new();
299 let mut file_dependencies = if source_url.is_empty() {
300 None
301 } else {
302 let mut set = FxHashSet::default();
303 set.insert(PathBuf::from(&source_url));
304 Some(set)
305 };
306
307 let sources = source_map
309 .sources()
310 .iter()
311 .map(|source| source.to_string())
312 .collect::<Vec<_>>();
313 let source_root = source_map.source_root().map(|s| s.to_string());
314
315 let source_contents: Vec<Option<String>> = (0..sources.len())
317 .map(|i| source_map.get_source_content(i).map(|s| s.to_string()))
318 .collect();
319
320 let mut futures = FuturesOrdered::new();
322
323 for (source, original_content) in sources.into_iter().zip(source_contents) {
325 let skip_reading = original_content.is_some();
326 let source_root = source_root.clone();
327 let context = context.to_path_buf();
328
329 let fs = fs.clone();
330 futures.push_back(async move {
331 let result =
332 fetch_from_url(&fs, &context, &source, source_root.as_deref(), skip_reading).await;
333 (original_content, skip_reading, result)
334 });
335 }
336
337 while let Some((original_content, skip_reading, result)) = futures.next().await {
339 let (source_url_result, source_content_result) = result?;
340
341 let final_content = if skip_reading {
342 original_content
343 } else {
344 source_content_result
345 };
346
347 if !skip_reading && !source_url_result.is_empty() && !is_url(&source_url_result) {
348 if let Some(ref mut deps) = file_dependencies {
349 deps.insert(PathBuf::from(&source_url_result));
350 } else {
351 let mut set = FxHashSet::default();
352 set.insert(PathBuf::from(&source_url_result));
353 file_dependencies = Some(set);
354 }
355 }
356
357 resolved_sources.push((source_url_result, final_content));
358 }
359
360 let (sources_vec, sources_content_vec): (Vec<String>, Vec<Cow<'_, str>>) = resolved_sources
362 .into_iter()
363 .map(|(url, content)| (url, Cow::Owned(content.unwrap_or_default())))
364 .unzip();
365
366 source_map.set_sources(sources_vec);
367 source_map.set_sources_content(sources_content_vec);
368
369 source_map.set_source_root(None);
371
372 let new_source = if replacement_string.is_empty() {
374 input.to_string()
375 } else {
376 input.cow_replace(&replacement_string, "").into_owned()
377 };
378
379 Ok(ExtractSourceMapResult {
380 source: new_source,
381 source_map: Some(source_map),
382 file_dependencies,
383 })
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn test_get_source_mapping_url() {
392 let test_cases = vec![
394 (
395 "/*#sourceMappingURL=absolute-sourceRoot-source-map.map*/",
396 "absolute-sourceRoot-source-map.map",
397 ),
398 (
399 "/* #sourceMappingURL=absolute-sourceRoot-source-map.map */",
400 "absolute-sourceRoot-source-map.map",
401 ),
402 (
403 "//#sourceMappingURL=absolute-sourceRoot-source-map.map",
404 "absolute-sourceRoot-source-map.map",
405 ),
406 (
407 "//@sourceMappingURL=absolute-sourceRoot-source-map.map",
408 "absolute-sourceRoot-source-map.map",
409 ),
410 (
411 " // #sourceMappingURL=absolute-sourceRoot-source-map.map",
412 "absolute-sourceRoot-source-map.map",
413 ),
414 (
415 " // # sourceMappingURL = absolute-sourceRoot-source-map.map ",
416 "absolute-sourceRoot-source-map.map",
417 ),
418 (
419 "// #sourceMappingURL = http://hello.com/external-source-map2.map",
420 "http://hello.com/external-source-map2.map",
421 ),
422 (
423 "// #sourceMappingURL = //hello.com/external-source-map2.map",
424 "//hello.com/external-source-map2.map",
425 ),
426 (
427 "// @sourceMappingURL=data:application/source-map;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5saW5lLXNvdXJjZS1tYXAuanMiLCJzb3VyY2VzIjpbImlubGluZS1zb3VyY2UtbWFwLnR4dCJdLCJzb3VyY2VzQ29udGVudCI6WyJ3aXRoIFNvdXJjZU1hcCJdLCJtYXBwaW5ncyI6IkFBQUEifQ==",
428 "data:application/source-map;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5saW5lLXNvdXJjZS1tYXAuanMiLCJzb3VyY2VzIjpbImlubGluZS1zb3VyY2UtbWFwLnR4dCJdLCJzb3VyY2VzQ29udGVudCI6WyJ3aXRoIFNvdXJjZU1hcCJdLCJtYXBwaW5ncyI6IkFBQUEifQ==",
429 ),
430 (
431 r#"
432 with SourceMap
433
434 // #sourceMappingURL = /sample-source-map.map
435 // comment
436 "#,
437 "/sample-source-map.map",
438 ),
439 (
440 r#"
441 with SourceMap
442 // #sourceMappingURL = /sample-source-map-1.map
443 // #sourceMappingURL = /sample-source-map-2.map
444 // #sourceMappingURL = /sample-source-map-last.map
445 // comment
446 "#,
447 "/sample-source-map-last.map",
448 ),
449 (
451 r#""
452 /*# sourceMappingURL=data:application/json;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap))))+" */";"#,
453 "",
454 ),
455 (
457 r#"// # sourceMappingURL=data:application/json;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap))))+"'"#,
458 "data:application/json;base64,",
459 ),
460 (
462 r#"anInvalidDirective = "
463/*# sourceMappingURL=data:application/json;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap))))+" */";"#,
464 "",
465 ),
466 ];
467
468 for (input, expected) in test_cases {
469 let result = get_source_mapping_url(input);
470 assert_eq!(
471 result.source_mapping_url, expected,
472 "Failed for input: {input}"
473 );
474 }
475 }
476
477 #[test]
478 fn test_get_source_mapping_url_empty_cases() {
479 let cases = vec![
481 "const foo = 'bar';",
482 "// This is a regular comment",
483 "/* Multi-line\n comment\n without sourceMappingURL */",
484 "",
485 ];
486
487 for case in cases {
488 let result = get_source_mapping_url(case);
489 assert!(result.source_mapping_url.is_empty());
490 assert!(result.replacement_string.is_empty());
491 }
492 }
493 #[tokio::test]
494 async fn test_extract_source_map_normalization() {
495 let context = Utf8Path::new("/context");
496
497 let path = get_absolute_path(context, "../foo.js", None);
499 assert_eq!(path.as_str(), "/foo.js");
500
501 let path = get_absolute_path(context, "a/../../b/c.js", None);
502 assert_eq!(path.as_str(), "/b/c.js");
503
504 let fs: Arc<dyn ReadableFileSystem> = Arc::new(rspack_fs::MemoryFileSystem::default());
506
507 let (path, _) = fetch_from_url(&fs, context, "/a/b/../c.js", None, true)
508 .await
509 .unwrap();
510 assert_eq!(path, "/a/c.js");
511 }
512}