Skip to main content

quickjs_runtime/typescript/
mod.rs

1// public transpile function which can also be used by gcs to transpile clientside ts
2
3use crate::jsutils::JsError;
4use crate::jsutils::Script;
5use crate::quickjs_utils::modules::detect_module;
6use crate::quickjsruntimeadapter::QuickJsRuntimeAdapter;
7use std::cell::RefCell;
8use std::collections::HashMap;
9use std::io;
10use std::str::FromStr;
11use std::sync::Arc;
12use swc::Compiler;
13use swc_common::errors::{ColorConfig, Handler};
14use swc_common::{FileName, SourceMap};
15
16pub enum TargetVersion {
17    Es3,
18    Es5,
19    Es2016,
20    Es2020,
21    Es2021,
22    Es2022,
23    Es2023,
24    Es2024,
25    Es2025,
26}
27
28impl TargetVersion {
29    fn as_str(&self) -> &str {
30        match self {
31            TargetVersion::Es3 => "es3",
32            TargetVersion::Es5 => "es5",
33            TargetVersion::Es2016 => "es2016",
34            TargetVersion::Es2020 => "es2020",
35            TargetVersion::Es2021 => "es2021",
36            TargetVersion::Es2022 => "es2022",
37            TargetVersion::Es2023 => "es2023",
38            TargetVersion::Es2024 => "es2024",
39            TargetVersion::Es2025 => "esnext",
40        }
41    }
42}
43
44pub struct TypeScriptTranspiler {
45    minify: bool,
46    mangle: bool,
47    external_helpers: bool,
48    target: TargetVersion,
49    compiler: Compiler,
50    source_map: Arc<SourceMap>,
51}
52
53impl TypeScriptTranspiler {
54    pub fn new(target: TargetVersion, minify: bool, external_helpers: bool, mangle: bool) -> Self {
55        let source_map = Arc::<SourceMap>::default();
56        let compiler = swc::Compiler::new(source_map.clone());
57
58        Self {
59            minify,
60            mangle,
61            external_helpers,
62            target,
63            source_map,
64            compiler,
65        }
66    }
67    // todo custom target
68    pub fn transpile(
69        &self,
70        code: &str,
71        file_name: &str,
72        is_module: bool,
73    ) -> Result<(String, Option<String>), JsError> {
74        let globals = swc_common::Globals::new();
75        let code = code.to_string();
76        swc_common::GLOBALS.set(&globals, || {
77            let handler = Handler::with_tty_emitter(
78                ColorConfig::Auto,
79                true,
80                false,
81                Some(self.source_map.clone()),
82            );
83
84            let fm = self
85                .source_map
86                .new_source_file(Arc::new(FileName::Custom(file_name.into())), code);
87
88            let mangle_config = if self.mangle {
89                r#"
90                    {
91                        "topLevel": false,
92                        "keepClassNames": true
93                    }
94                "#
95            } else {
96                "false"
97            };
98
99            let minify_options = if self.minify {
100                format!(
101                    r#"
102                "minify": {{
103                  "compress": {{
104                    "unused": true
105                  }},
106                  "format": {{
107                    "comments": false
108                  }},
109                  "mangle": {mangle_config}
110                }},
111            "#
112                )
113            } else {
114                r#"
115                "minify": {
116                  "format": {
117                    "comments": false
118                  }
119                },
120                "#
121                .to_string()
122            };
123
124            let module = if is_module {
125                r#"
126                "module": {
127                    "type": "es6",
128                    "strict": true,
129                    "strictMode": true,
130                    "lazy": false,
131                    "noInterop": false,
132                    "ignoreDynamic": true
133                },
134                "#
135            } else {
136                ""
137            };
138
139            let cfg_json = format!(
140                r#"
141
142            {{
143              "minify": {},
144              "sourceMaps": true,
145              {}
146              "jsc": {{
147                {}
148                "externalHelpers": {},
149                "parser": {{
150                  "syntax": "typescript",
151                  "jsx": true,
152                  "tsx": true,
153                  "decorators": true,
154                  "decoratorsBeforeExport": true,
155                  "dynamicImport": true,
156                  "preserveAllComments": false
157                }},
158                "transform": {{
159                  "legacyDecorator": true,
160                  "decoratorMetadata": true,
161                  "react": {{
162                      "runtime": "classic",
163                      "useBuiltins": true,
164                      "refresh": true
165                  }}
166                }},
167                "target": "{}",
168                "keepClassNames": true
169              }}
170            }}
171
172        "#,
173                self.minify,
174                module,
175                minify_options,
176                self.external_helpers,
177                self.target.as_str()
178            );
179
180            log::trace!("using config {}", cfg_json);
181
182            let cfg = serde_json::from_str(cfg_json.as_str())
183                .map_err(|e| JsError::new_string(format!("{e}")))?;
184
185            let ops = swc::config::Options {
186                config: cfg,
187                ..Default::default()
188            };
189
190            // todo see https://github.com/swc-project/swc/discussions/4126
191            // for better example
192
193            let res = self.compiler.process_js_file(fm, &handler, &ops);
194
195            match res {
196                Ok(to) => Ok((to.code, to.map)),
197                Err(e) => Err(JsError::new_string(format!("transpile failed: {e}"))),
198            }
199        })
200    }
201
202    pub fn transpile_script(&self, script: &mut Script) -> Result<(), JsError> {
203        if script.get_path().ends_with(".ts") {
204            let code = script.get_code();
205
206            let is_module = detect_module(code);
207
208            let js = self.transpile(code, script.get_path(), is_module)?;
209            log::debug!("map: {:?}", js.1);
210            script.set_transpiled_code(js.0, js.1);
211        }
212        log::debug!(
213            "TypeScriptPreProcessor:process file={} result = {}",
214            script.get_path(),
215            script.get_runnable_code()
216        );
217
218        Ok(())
219    }
220}
221
222impl Default for TypeScriptTranspiler {
223    fn default() -> Self {
224        Self::new(TargetVersion::Es2025, false, false, false)
225    }
226}
227
228thread_local! {
229    // we store this in a thread local inb the worker thread so they are dropped when the runtimefacade is dropped
230    static SOURCE_MAPS: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
231    static TRANSPILER: RefCell<TypeScriptTranspiler> = RefCell::new(TypeScriptTranspiler::new(TargetVersion::Es2025, false, false, false));
232}
233
234// fix stacktrace method
235pub(crate) fn transpile_serverside(
236    _rt: &QuickJsRuntimeAdapter,
237    script: &mut Script,
238) -> Result<(), JsError> {
239    // transpile and store map in qjsrt
240
241    // transpile
242    TRANSPILER.with(|rc| {
243        let transpiler: &TypeScriptTranspiler = &rc.borrow();
244        transpiler.transpile_script(script)
245    })?;
246
247    // register in source_maps so fix_stack can use it later
248    if let Some(map_str) = script.get_map() {
249        SOURCE_MAPS.with(|rc| {
250            let maps = &mut *rc.borrow_mut();
251            maps.insert(script.get_path().to_string(), map_str.to_string());
252        })
253    }
254    Ok(())
255}
256
257#[derive(Debug)]
258struct StackEntry {
259    function_name: String,
260    file_name: String,
261    line_number: Option<u32>,
262    column_number: Option<u32>,
263}
264
265impl FromStr for StackEntry {
266    type Err = String;
267
268    fn from_str(s: &str) -> Result<Self, Self::Err> {
269        // remove 'at '
270        let s = &s[3..];
271
272        let mut parts = s.splitn(2, ' ');
273        let function_name = parts.next().unwrap_or("unnamed").to_string();
274        let mut file_name = parts.next().unwrap_or("(unknown)").to_string();
275        if file_name.starts_with('(') {
276            file_name = file_name.as_str()[1..].to_string();
277        }
278        if file_name.ends_with(')') {
279            file_name = file_name.as_str()[..file_name.len() - 1].to_string();
280        }
281        file_name = file_name.replace("://", "_double_point_placeholder_//");
282
283        let parts: Vec<&str> = file_name.split(':').collect();
284
285        let file_name = parts[0]
286            .to_string()
287            .replace("_double_point_placeholder_//", "://");
288        let line_number = parts.get(1).and_then(|s| s.parse::<u32>().ok());
289        let column_number = parts.get(2).and_then(|s| s.parse::<u32>().ok());
290
291        Ok(StackEntry {
292            function_name,
293            file_name,
294            column_number,
295            line_number,
296        })
297    }
298}
299
300fn parse_stack_trace(stack_trace: &str) -> Result<Vec<StackEntry>, String> {
301    let entries: Vec<StackEntry> = stack_trace
302        .lines()
303        .map(|line| line.trim())
304        .filter(|line| !line.is_empty())
305        .map(|line| line.parse::<StackEntry>())
306        .collect::<Result<Vec<_>, _>>()?;
307
308    Ok(entries)
309}
310
311fn serialize_stack(entries: &[StackEntry]) -> String {
312    let mut result = String::new();
313
314    for entry in entries {
315        let fname_lnum = if let Some(line_number) = entry.line_number {
316            if let Some(column_number) = entry.column_number {
317                format!("{}:{line_number}:{column_number}", entry.file_name)
318            } else {
319                format!("{}:{line_number}", entry.file_name)
320            }
321        } else {
322            entry.file_name.clone()
323        };
324
325        result.push_str(&format!("    at {} ({fname_lnum})", entry.function_name));
326
327        result.push('\n');
328    }
329
330    result
331}
332
333pub(crate) fn unmap_stack_trace(stack_trace: &str) -> String {
334    // todo: not the fastest way to impl this.. should I keep instances of source map instead of string? what does that do to mem consumtion?
335    SOURCE_MAPS.with(|rc| fix_stack_trace(stack_trace, &rc.borrow()))
336}
337
338pub fn fix_stack_trace(stack_trace: &str, maps: &HashMap<String, String>) -> String {
339    log::trace!("fix_stack_trace:\n{stack_trace}");
340
341    match parse_stack_trace(stack_trace) {
342        Ok(mut parsed_stack) => {
343            for stack_trace_entry in parsed_stack.iter_mut() {
344                if let Some(map_str) = maps.get(stack_trace_entry.file_name.as_str()) {
345                    log::trace!(
346                        "fix_stack_trace:found map for file {}:\n{map_str}",
347                        stack_trace_entry.file_name.as_str()
348                    );
349                    if let Some(line_number) = stack_trace_entry.line_number {
350                        log::trace!("lookup line number:{line_number}");
351                        match swc::sourcemap::SourceMap::from_reader(io::Cursor::new(map_str)) {
352                            Ok(source_map) => {
353                                if let Some(original_location) = source_map.lookup_token(
354                                    line_number - 1,
355                                    stack_trace_entry.column_number.unwrap_or(1) - 1,
356                                ) {
357                                    let original_line = original_location.get_src_line();
358                                    let original_column = original_location.get_src_col();
359                                    log::trace!("lookup original_line:{original_line}");
360                                    stack_trace_entry.line_number = Some(original_line + 1);
361                                    stack_trace_entry.column_number = Some(original_column + 1);
362                                }
363                            }
364                            Err(_) => {
365                                log::trace!(
366                                    "could not parse source_map for {}",
367                                    stack_trace_entry.file_name.as_str()
368                                );
369                            }
370                        }
371                    }
372                } else {
373                    log::trace!("no map found for {}", stack_trace_entry.file_name.as_str());
374                }
375
376                // Now you have the original filename and line number
377                // You can use them as needed
378            }
379
380            let ret = serialize_stack(&parsed_stack);
381            log::trace!("fix_stack_trace ret:\n{ret}");
382            ret
383        }
384        Err(_) => {
385            log::error!("could not parse stack: \n{}", stack_trace);
386            stack_trace.to_string()
387        }
388    }
389}
390
391#[cfg(test)]
392pub mod tests {
393    use crate::facades::tests::init_test_rt;
394    use crate::jsutils::{JsValueType, Script};
395    use crate::typescript::{parse_stack_trace, serialize_stack};
396
397    #[test]
398    fn test_ts() {
399        let rt = init_test_rt();
400        println!("testing ts");
401        let script = Script::new(
402            "test.ts",
403            r#"
404            // hi
405            // ho
406            function t_ts(a: string, b: num): boolean {
407                return true;
408            }
409            t_ts("hello", 1337);
410        "#,
411        );
412        let res = rt.eval_sync(None, script).expect("script failed");
413        assert!(res.get_value_type() == JsValueType::Boolean);
414    }
415    #[test]
416    fn test_stack_map() {
417        let rt = init_test_rt();
418        println!("testing ts");
419        let script = Script::new(
420            "test.ts",
421            r#"
422            
423            type Nonsense = {
424                hello: string
425            };
426            
427            function t_ts(a: string, b: num): boolean {
428                return a.a.a === "hi";
429            }
430
431            t_ts("hello", 1337);
432"#,
433        );
434        let res = rt
435            .eval_sync(None, script)
436            .expect_err("script passed.. which it shouldnt");
437        // far from perfect test, also line numbers don't yet realy match..
438        // check again after https://github.com/HiRoFa/quickjs_es_runtime/issues/77
439        println!("stack:{}", res.get_stack());
440
441        // bellard is actually better as it points at the actual issue, not the start of the function
442
443        #[cfg(feature = "bellard")]
444        assert!(res.get_stack().contains("t_ts (test.ts:8"));
445        #[cfg(feature = "quickjs-ng")]
446        assert!(res.get_stack().contains("t_ts (test.ts:7"));
447    }
448    #[test]
449    fn test_stack_parse() {
450        // just to init logging;
451        let _rt = init_test_rt();
452
453        let stack = r#"
454            at func (file.ts:88:12)
455            at doWriteTransactioned (gcsproject:///gcs_objectstore/ObjectStore.ts:170)
456        "#;
457        match parse_stack_trace(stack) {
458            Ok(a) => {
459                assert_eq!(a[0].file_name, "file.ts");
460                assert_eq!(a[0].line_number, Some(88));
461                assert_eq!(a[0].column_number, Some(12));
462                assert_eq!(a[0].function_name, "func");
463
464                assert_eq!(
465                    a[1].file_name,
466                    "gcsproject:///gcs_objectstore/ObjectStore.ts"
467                );
468                assert_eq!(a[1].line_number, Some(170));
469                assert_eq!(a[1].column_number, None);
470                assert_eq!(a[1].function_name, "doWriteTransactioned");
471
472                assert_eq!(
473                    serialize_stack(&a).as_str(),
474                    r#"    at func (file.ts:88:12)
475    at doWriteTransactioned (gcsproject:///gcs_objectstore/ObjectStore.ts:170)
476"#
477                );
478            }
479            Err(e) => {
480                panic!("{}", e);
481            }
482        }
483    }
484}