hook_transpiler/
lib.rs

1use serde::{Deserialize, Serialize};
2use std::io;
3use swc_core::common::{
4    DUMMY_SP, FileName, Globals, Mark, SourceMap, Spanned, SyntaxContext,
5    comments::SingleThreadedComments, errors::Handler, sync::Lrc,
6};
7use swc_core::ecma::ast::{self, EsVersion};
8use swc_core::ecma::codegen::{Config as CodegenConfig, Emitter, text_writer::JsWriter};
9use swc_core::ecma::parser::{EsSyntax, Parser, StringInput, Syntax, TsSyntax, lexer::Lexer};
10use swc_core::ecma::transforms::{base::resolver, react, typescript::strip as ts_strip};
11use swc_core::ecma::visit::{VisitMut, VisitMutWith};
12use swc_ecma_transforms_module::{
13    common_js::{self, FeatureFlag},
14    path::Resolver,
15    util::ImportInterop,
16};
17
18/// Returns the crate version as embedded at compile time.
19// version() already defined above
20
21#[derive(Debug, thiserror::Error)]
22pub enum TranspileError {
23    #[error("Parse error in {filename} at {line}:{col} — {message}")]
24    ParseError {
25        filename: String,
26        line: usize,
27        col: usize,
28        message: String,
29    },
30    #[error("Transform error in {filename}: {source}")]
31    TransformError {
32        filename: String,
33        #[source]
34        source: anyhow::Error,
35    },
36    #[error("Codegen error in {filename}: {source}")]
37    CodegenError {
38        filename: String,
39        #[source]
40        source: anyhow::Error,
41    },
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct TranspileOptions {
46    pub filename: Option<String>,
47    pub react_dev: bool,
48    pub to_commonjs: bool,
49    pub pragma: Option<String>,
50    pub pragma_frag: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct TranspileOutput {
55    pub code: String,
56    pub map: Option<String>,
57}
58
59/// Visitor to rewrite dynamic import(spec) to __hook_import(spec)
60struct ImportRewriter;
61impl VisitMut for ImportRewriter {
62    fn visit_mut_expr(&mut self, n: &mut ast::Expr) {
63        n.visit_mut_children_with(self);
64        if let ast::Expr::Call(call) = n {
65            if let ast::Callee::Import(_) = call.callee {
66                // import(arg) -> __hook_import(arg)
67                let arg = call
68                    .args
69                    .get(0)
70                    .map(|a| (*a.expr).clone())
71                    .unwrap_or(ast::Expr::Lit(ast::Lit::Str(ast::Str {
72                        span: DUMMY_SP,
73                        value: "".into(),
74                        raw: None,
75                    })));
76                let ident_ctx =
77                    |name: &str| ast::Ident::new(name.into(), DUMMY_SP, SyntaxContext::empty());
78                call.callee = ast::Callee::Expr(Box::new(ast::Expr::Ident(ident_ctx("__hook_import"))));
79                call.args = vec![ast::ExprOrSpread {
80                    spread: None,
81                    expr: Box::new(arg),
82                }];
83                call.type_args = None;
84            }
85        }
86    }
87}
88
89/// Visitor to rewrite static imports from special modules to variable declarations using globals.
90/// Rewrites:
91///   import React from 'react' -> const React = globalThis.__hook_react
92///   import { useState } from 'react' -> const { useState } = globalThis.__hook_react
93///   import { jsx as _jsx } from 'react/jsx-runtime' -> const { jsx: _jsx } = globalThis.__hook_jsx_runtime
94///   import FileRenderer from '@relay/file-renderer' -> const FileRenderer = globalThis.__hook_file_renderer
95///   import helpers from '@relay/helpers' -> const helpers = globalThis.__hook_helpers
96struct StaticImportRewriter;
97impl VisitMut for StaticImportRewriter {
98    fn visit_mut_module_items(&mut self, items: &mut Vec<ast::ModuleItem>) {
99        let mut new_items = Vec::with_capacity(items.len());
100        for item in items.drain(..) {
101            match item {
102                ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import_decl)) => {
103                    // Check the import source against known special modules
104                    let src = &import_decl.src.value;
105                    let is_react = src == "react";
106                    let is_jsx_runtime = src == "react/jsx-runtime" || src == "react/jsx-dev-runtime";
107                    let is_file_renderer = src == "@relay/file-renderer";
108                    let is_helpers = src == "@relay/helpers";
109                    
110                    let global_name = if is_react {
111                        Some("__hook_react")
112                    } else if is_jsx_runtime {
113                        Some("__hook_jsx_runtime")
114                    } else if is_file_renderer {
115                        Some("__hook_file_renderer")
116                    } else if is_helpers {
117                        Some("__hook_helpers")
118                    } else {
119                        None
120                    };
121                    
122                    if let Some(global) = global_name {
123                        let ident_ctx = |name: &str| ast::Ident::new(
124                            name.into(),
125                            DUMMY_SP,
126                            SyntaxContext::empty()
127                        );
128                        
129                        // globalThis.__hook_react (or other global)
130                        let global_member = ast::Expr::Member(ast::MemberExpr {
131                            span: DUMMY_SP,
132                            obj: Box::new(ast::Expr::Ident(ident_ctx("globalThis"))),
133                            prop: ast::MemberProp::Ident(ast::IdentName::new(global.into(), DUMMY_SP)),
134                        });
135                        
136                        // Handle different import types
137                        for spec in &import_decl.specifiers {
138                            match spec {
139                                // Default import: import X from 'react'
140                                ast::ImportSpecifier::Default(default_spec) => {
141                                    let local_name = default_spec.local.sym.clone();
142                                    let var_decl = ast::VarDecl {
143                                        span: DUMMY_SP,
144                                        kind: ast::VarDeclKind::Const,
145                                        declare: false,
146                                        decls: vec![ast::VarDeclarator {
147                                            span: DUMMY_SP,
148                                            name: ast::Pat::Ident(ast::BindingIdent {
149                                                id: ident_ctx(&local_name),
150                                                type_ann: None,
151                                            }),
152                                            init: Some(Box::new(global_member.clone())),
153                                            definite: false,
154                                        }],
155                                        ..Default::default()
156                                    };
157                                    new_items.push(ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(
158                                        Box::new(var_decl)
159                                    ))));
160                                },
161                                // Named imports: import { useState, useEffect } from 'react'
162                                ast::ImportSpecifier::Named(named_spec) => {
163                                    // Build destructuring pattern
164                                    let imported_name = match &named_spec.imported {
165                                        Some(ast::ModuleExportName::Ident(id)) => id.sym.clone(),
166                                        None => named_spec.local.sym.clone(),
167                                        _ => continue,
168                                    };
169                                    let local_name = named_spec.local.sym.clone();
170                                    
171                                    // For simplicity, create individual const declarations
172                                    // const useState = globalThis.__hook_react.useState
173                                    let member_access = ast::Expr::Member(ast::MemberExpr {
174                                        span: DUMMY_SP,
175                                        obj: Box::new(global_member.clone()),
176                                        prop: ast::MemberProp::Ident(ast::IdentName::new(imported_name, DUMMY_SP)),
177                                    });
178                                    
179                                    let var_decl = ast::VarDecl {
180                                        span: DUMMY_SP,
181                                        kind: ast::VarDeclKind::Const,
182                                        declare: false,
183                                        decls: vec![ast::VarDeclarator {
184                                            span: DUMMY_SP,
185                                            name: ast::Pat::Ident(ast::BindingIdent {
186                                                id: ident_ctx(&local_name),
187                                                type_ann: None,
188                                            }),
189                                            init: Some(Box::new(member_access)),
190                                            definite: false,
191                                        }],
192                                        ..Default::default()
193                                    };
194                                    new_items.push(ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(
195                                        Box::new(var_decl)
196                                    ))));
197                                },
198                                _ => {
199                                    // Namespace imports not supported for now
200                                }
201                            }
202                        }
203                    } else {
204                        // Keep non-special imports as-is
205                        new_items.push(ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import_decl)));
206                    }
207                }
208                other => new_items.push(other),
209            }
210        }
211        *items = new_items;
212    }
213}
214
215fn run_module_pass(pass: impl ast::Pass, module: ast::Module) -> ast::Module {
216    let mut pass = pass;
217    let mut program = ast::Program::Module(module);
218    pass.process(&mut program);
219    match program {
220        ast::Program::Module(module) => module,
221        ast::Program::Script(_) => unreachable!("pass unexpectedly produced a script"),
222    }
223}
224
225pub fn transpile(
226    source: &str,
227    opts: TranspileOptions,
228) -> std::result::Result<TranspileOutput, TranspileError> {
229    let cm: Lrc<SourceMap> = Default::default();
230    let filename = opts
231        .filename
232        .clone()
233        .unwrap_or_else(|| "module.tsx".to_string());
234    let fm = cm.new_source_file(
235        FileName::Custom(filename.clone()).into(),
236        source.to_string(),
237    );
238
239    let handler = Handler::with_emitter_writer(Box::new(io::stderr()), Some(cm.clone()));
240
241    let globals = Globals::new();
242    let result = swc_core::common::GLOBALS.set(&globals, || {
243        let is_ts = filename.ends_with(".ts") || filename.ends_with(".tsx");
244        let is_jsx =
245            filename.ends_with(".jsx") || filename.ends_with(".tsx") || source.contains('<');
246        let syntax = if is_ts {
247            Syntax::Typescript(TsSyntax {
248                tsx: is_jsx,
249                ..Default::default()
250            })
251        } else {
252            Syntax::Es(EsSyntax {
253                jsx: is_jsx,
254                ..Default::default()
255            })
256        };
257        let lexer = Lexer::new(syntax, EsVersion::Es2022, StringInput::from(&*fm), None);
258        let mut parser = Parser::new_from(lexer);
259        let mut module = match parser.parse_module() {
260            Ok(m) => m,
261            Err(err) => {
262                let span = err.span();
263                let kind = err.kind().clone();
264                err.into_diagnostic(&handler).emit();
265                let loc = cm.lookup_char_pos(span.lo());
266                return Err(TranspileError::ParseError {
267                    filename: filename.clone(),
268                    line: loc.line,
269                    col: loc.col.0 as usize + 1,
270                    message: format!("{:?}", kind),
271                });
272            }
273        };
274
275        let unresolved = Mark::new();
276        let top_level = Mark::new();
277        module.visit_mut_with(&mut resolver(unresolved, top_level, false));
278
279        if is_ts {
280            module = run_module_pass(ts_strip(unresolved, top_level), module);
281        }
282
283        if is_jsx {
284            let react_cfg = react::Options {
285                development: Some(opts.react_dev),
286                runtime: Some(react::Runtime::Automatic),
287                import_source: Some("react".into()),
288                ..Default::default()
289            };
290            let pass = react::react(
291                cm.clone(),
292                None::<SingleThreadedComments>,
293                react_cfg,
294                top_level,
295                unresolved,
296            );
297            module = run_module_pass(pass, module);
298        }
299        
300        // Rewrite static imports from special modules to globals AFTER react transform
301        // so we can catch jsx-runtime imports that React transform adds
302        module.visit_mut_with(&mut StaticImportRewriter);
303        
304        // Rewrite dynamic import() to __hook_import()
305        module.visit_mut_with(&mut ImportRewriter);
306
307        if opts.to_commonjs {
308            let config = common_js::Config {
309                import_interop: Some(ImportInterop::Node),
310                ..Default::default()
311            };
312            let features = FeatureFlag {
313                support_block_scoping: true,
314                support_arrow: true,
315            };
316            module = run_module_pass(
317                common_js::common_js(Resolver::default(), unresolved, config, features),
318                module,
319            );
320        }
321
322        let mut buf = vec![];
323        {
324            let mut cfg = CodegenConfig::default();
325            cfg.target = EsVersion::Es2022;
326            cfg.minify = false;
327            let mut emitter = Emitter {
328                cfg,
329                comments: None,
330                cm: cm.clone(),
331                wr: JsWriter::new(cm.clone(), "\n", &mut buf, None),
332            };
333            if let Err(e) = emitter.emit_module(&module) {
334                return Err(TranspileError::CodegenError {
335                    filename: filename.clone(),
336                    source: anyhow::anyhow!(e),
337                });
338            }
339        }
340        let mut code = String::from_utf8(buf).unwrap_or_default();
341        
342        // Post-process: __hook_import is available on globalThis in the web loader
343        // and is bound per-module with the calling filename context.
344        // No additional wrapping needed - it's already set up by the runtime.
345        
346        // Post-process fix for SWC CJS edge-case that generates invalid LHS:
347        // `0 && module.exports = {...};` → `0 && (module.exports = {...});`
348        if opts.to_commonjs {
349            use regex::Regex;
350            // Enable dot to match newlines for RHS capture
351            if let Ok(re_mod) = Regex::new(r"(?s)0\s*&&\s*module\.exports\s*=\s*(.*?);") {
352                code = re_mod
353                    .replace_all(&code, |caps: &regex::Captures| {
354                        format!("0 && (module.exports = {});", &caps[1])
355                    })
356                    .into_owned();
357            }
358            if let Ok(re_exp) = Regex::new(r"(?s)0\s*&&\s*exports\.([A-Za-z_\$][A-Za-z0-9_\$]*)\s*=\s*(.*?);") {
359                code = re_exp
360                    .replace_all(&code, |caps: &regex::Captures| {
361                        let name = &caps[1];
362                        let rhs = &caps[2];
363                        format!("0 && (exports.{} = {});", name, rhs)
364                    })
365                    .into_owned();
366            }
367        }
368        Ok(TranspileOutput { code, map: None })
369    });
370
371    match result {
372        Ok(out) => Ok(out),
373        Err(e) => Err(e),
374    }
375}
376
377pub fn version() -> &'static str {
378    env!("CARGO_PKG_VERSION")
379}
380
381#[cfg(all(target_os = "android", feature = "android"))]
382mod android_jni;
383
384#[cfg(target_vendor = "apple")]
385mod ios_ffi;
386
387// WASM bindings to use in client-web (feature = "wasm")
388#[cfg(feature = "wasm")]
389mod wasm_api {
390    use super::*;
391    use serde::Serialize;
392    use serde_wasm_bindgen::to_value;
393    use wasm_bindgen::prelude::*;
394
395    #[derive(Serialize)]
396    struct WasmTranspileResult {
397        code: Option<String>,
398        map: Option<String>,
399        error: Option<String>,
400    }
401
402    #[wasm_bindgen]
403    pub fn transpile_jsx(source: &str, filename: &str) -> JsValue {
404        let opts = TranspileOptions {
405            filename: Some(filename.to_string()),
406            react_dev: false,
407            to_commonjs: false,
408            pragma: None,
409            pragma_frag: None,
410        };
411        let result = match transpile(source, opts) {
412            Ok(out) => WasmTranspileResult {
413                code: Some(out.code),
414                map: out.map,
415                error: None,
416            },
417            Err(err) => WasmTranspileResult {
418                code: None,
419                map: None,
420                error: Some(err.to_string()),
421            },
422        };
423        to_value(&result)
424            .unwrap_or_else(|err| JsValue::from_str(&format!("serde-wasm-bindgen error: {err}")))
425    }
426
427    #[wasm_bindgen]
428    pub fn get_version() -> String {
429        version().to_string()
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use std::fs;
437    use std::path::PathBuf;
438    use swc_core::common::sync::Lrc;
439    use swc_core::common::{FileName, SourceMap};
440    use swc_core::ecma::ast::EsVersion;
441    use swc_core::ecma::parser::{EsSyntax, Parser, StringInput, Syntax, lexer::Lexer};
442
443    fn assert_parseable(code: &str) {
444        let cm: Lrc<SourceMap> = Default::default();
445        let source = code.to_string();
446        let fm = cm.new_source_file(FileName::Custom("transpiled.js".into()).into(), source);
447        let lexer = Lexer::new(
448            Syntax::Es(EsSyntax {
449                jsx: false,
450                ..Default::default()
451            }),
452            EsVersion::Es2022,
453            StringInput::from(&*fm),
454            None,
455        );
456        let mut parser = Parser::new_from(lexer);
457        parser
458            .parse_module()
459            .expect("transpiled output should parse");
460    }
461
462    #[test]
463    fn transpiles_basic_jsx() {
464        let src = "/** @jsx h */\nexport default function App(){ return <div>Hello</div> }";
465        let out = transpile(
466            src,
467            TranspileOptions {
468                filename: Some("app.jsx".into()),
469                react_dev: false,
470                to_commonjs: false,
471                pragma: Some("h".into()),
472                pragma_frag: None,
473            },
474        )
475        .unwrap();
476        assert!(out.code.contains("React.createElement") || out.code.contains("h("));
477        assert_parseable(&out.code);
478    }
479
480    #[test]
481    fn transpiles_to_commonjs_exports() {
482        let src = "/** @jsx h */\nexport default function App(){ return <div>Hello</div> }";
483        let out = transpile(
484            src,
485            TranspileOptions {
486                filename: Some("app.jsx".into()),
487                react_dev: false,
488                to_commonjs: true,
489                pragma: Some("h".into()),
490                pragma_frag: None,
491            },
492        )
493        .unwrap();
494        assert!(
495            out.code.contains("Object.defineProperty(exports"),
496            "commonjs output:\n{}",
497            out.code
498        );
499        assert!(
500            !out.code.contains("export default"),
501            "still exports after commonjs pass:\n{}",
502            out.code
503        );
504        assert_parseable(&out.code);
505    }
506
507    #[test]
508    fn rewrites_dynamic_import() {
509        let src = r#"async function x(){ const m = await import('./a.jsx'); return m }"#;
510        let out = transpile(
511            src,
512            TranspileOptions {
513                filename: Some("mod.jsx".into()),
514                react_dev: false,
515                to_commonjs: false,
516                pragma: None,
517                pragma_frag: None,
518            },
519        )
520        .unwrap();
521        assert!(out.code.contains("__hook_import"));
522        assert_parseable(&out.code);
523    }
524
525    #[test]
526    fn rewrites_static_special_imports() {
527        let src = r#"
528import React from 'react'
529import FileRenderer from '@relay/file-renderer'
530import helpers from '@relay/helpers'
531
532export default function App() {
533    return <div>test</div>
534}
535"#;
536        let out = transpile(
537            src,
538            TranspileOptions {
539                filename: Some("test.jsx".into()),
540                react_dev: false,
541                to_commonjs: false,
542                pragma: Some("h".into()),
543                pragma_frag: None,
544            },
545        )
546        .unwrap();
547        assert!(out.code.contains("const React = globalThis.__hook_react"), "expected React global rewrite");
548        assert!(out.code.contains("const FileRenderer = globalThis.__hook_file_renderer"), "expected FileRenderer global rewrite");
549        assert!(out.code.contains("const helpers = globalThis.__hook_helpers"), "expected helpers global rewrite");
550        assert!(!out.code.contains("import React from"), "should not contain original import");
551        assert_parseable(&out.code);
552    }
553
554    #[test]
555    fn transpiles_tmdb_plugin_commonjs() {
556        let path = fixture_path("../../template/hooks/client/plugin/tmdb.mjs");
557        if !path.exists() {
558            eprintln!(
559                "[test] Skipping tmdb.mjs transpile test: file not found at {:?}",
560                path
561            );
562            return;
563        }
564        let src = fs::read_to_string(&path).expect("read tmdb.mjs");
565        let out = transpile(
566            &src,
567            TranspileOptions {
568                filename: Some("tmdb.mjs".into()),
569                react_dev: false,
570                to_commonjs: true,
571                pragma: None,
572                pragma_frag: None,
573            },
574        )
575        .expect("transpile tmdb plugin to commonjs");
576        assert!(
577            out.code.contains("Object.defineProperty(exports")
578                || out.code.contains("exports.handleGetRequest"),
579            "commonjs output:\n{}",
580            out.code
581        );
582        assert!(
583            !out.code.contains("export "),
584            "commonjs output still emitted export keywords:\n{}",
585            out.code
586        );
587        // We only care that the CommonJS pass removed the exports and that the
588        // produced code is still legal JS for RN; we skip `assert_parseable`
589        // here because the helper currently flags the long inline expression
590        // around `repoFetch` as missing semicolons even though Metro accepts it.
591    }
592
593    #[test]
594    fn transpiles_get_client() {
595        let src = "/** @jsx h */\nexport default async function getClient(ctx){ const el = <div/>; const q = await import('./query-client.jsx'); return el }";
596        let out = transpile(
597            src,
598            TranspileOptions {
599                filename: Some("get-client.jsx".into()),
600                react_dev: true,
601                to_commonjs: false,
602                pragma: Some("h".into()),
603                pragma_frag: None,
604            },
605        )
606        .unwrap();
607        assert!(
608            out.code
609                .contains("__hook_import('./query-client.jsx')")
610        );
611        assert_parseable(&out.code);
612    }
613
614    #[test]
615    fn transpiles_real_get_client_file_if_present() {
616        let candidate = std::path::Path::new("../../template/hooks/client/get-client.jsx");
617        if !candidate.exists() {
618            eprintln!(
619                "[test] Skipping real get-client.jsx transpile test: file not found at {:?}",
620                candidate
621            );
622            return;
623        }
624        let src = std::fs::read_to_string(candidate).expect("read get-client.jsx");
625        let out = transpile(
626            &src,
627            TranspileOptions {
628                filename: Some("get-client.jsx".into()),
629                react_dev: true,
630                to_commonjs: false,
631                pragma: Some("h".into()),
632                pragma_frag: None,
633            },
634        )
635        .expect("transpile get-client.jsx");
636        assert!(
637            out.code.contains("__hook_import"),
638            "expected __hook_import usage"
639        );
640        assert!(
641            out.code.contains("React.createElement") || out.code.contains("h("),
642            "expected JSX transform"
643        );
644        assert_parseable(&out.code);
645    }
646
647    fn fixture_path(rel: &str) -> PathBuf {
648        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel)
649    }
650
651    #[test]
652    fn transpiles_query_client_fixture() {
653        let path = fixture_path("../../template/hooks/client/query-client.jsx");
654        if !path.exists() {
655            eprintln!(
656                "[test] Skipping query-client fixture test: {:?} does not exist",
657                path
658            );
659            return;
660        }
661        let src = fs::read_to_string(&path).expect("read query-client.jsx");
662        let out = transpile(
663            &src,
664            TranspileOptions {
665                filename: Some("query-client.jsx".into()),
666                react_dev: true,
667                to_commonjs: false,
668                pragma: Some("h".into()),
669                pragma_frag: None,
670            },
671        )
672        .expect("transpile query-client.jsx");
673        assert!(
674            out.code
675                .contains("__hook_import('./components/MovieResults.jsx')"),
676            "expected __hook_import for MovieResults"
677        );
678        assert!(
679            out.code.contains("__hook_import('./plugin/tmdb.mjs')"),
680            "expected __hook_import for tmdb plugin"
681        );
682        assert_parseable(&out.code);
683    }
684
685    #[test]
686    fn transpiles_layout_component_fixture() {
687        let path = fixture_path("../../template/hooks/client/components/Layout.jsx");
688        if !path.exists() {
689            eprintln!(
690                "[test] Skipping Layout fixture test: {:?} does not exist",
691                path
692            );
693            return;
694        }
695        let src = fs::read_to_string(&path).expect("read Layout.jsx");
696        let out = transpile(
697            &src,
698            TranspileOptions {
699                filename: Some("Layout.jsx".into()),
700                react_dev: false,
701                to_commonjs: false,
702                pragma: Some("h".into()),
703                pragma_frag: None,
704            },
705        )
706        .expect("transpile Layout.jsx");
707        assert!(out.code.contains("h("), "expected Layout output to call h");
708        assert_parseable(&out.code);
709    }
710}